Compare commits
@@ -23,3 +23,4 @@ CLAUDE.local.md
|
|||||||
.DS_Store
|
.DS_Store
|
||||||
.idea/
|
.idea/
|
||||||
.vscode/
|
.vscode/
|
||||||
|
.claude/worktrees/
|
||||||
|
|||||||
@@ -47,7 +47,7 @@ Use impeccable for user-facing UI: `craft` for new UI, `audit`/`polish` on exist
|
|||||||
- Plugins (`plugins/*`) import only `@sttracker/plugin-sdk`. They never import `packages/db` or core
|
- Plugins (`plugins/*`) import only `@sttracker/plugin-sdk`. They never import `packages/db` or core
|
||||||
internals, and they return `SourceRecord`s instead of writing.
|
internals, and they return `SourceRecord`s instead of writing.
|
||||||
- `packages/*` and `plugins/*` never import from `apps/*`.
|
- `packages/*` and `plugins/*` never import from `apps/*`.
|
||||||
- IDs are ULIDs generated in app code. Ratings are integers 0–100. Dates carry a precision
|
- IDs are UUIDv7 (`newId()`) generated in app code. Ratings are integers 0–100. Dates carry a precision
|
||||||
(`day | month | year | unknown`).
|
(`day | month | year | unknown`).
|
||||||
- Tests never call live APIs. Plugin tests use recorded fixtures through the SDK test harness.
|
- Tests never call live APIs. Plugin tests use recorded fixtures through the SDK test harness.
|
||||||
- Write-back (`push`) is opt-in per connection and off by default. No plugin pushes in Phase 1.
|
- Write-back (`push`) is opt-in per connection and off by default. No plugin pushes in Phase 1.
|
||||||
|
|||||||
@@ -0,0 +1,15 @@
|
|||||||
|
{
|
||||||
|
"name": "@sttracker/server",
|
||||||
|
"private": true,
|
||||||
|
"type": "module",
|
||||||
|
"exports": {
|
||||||
|
"./*": "./src/*.ts"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@sttracker/core": "workspace:*",
|
||||||
|
"@sttracker/db": "workspace:*",
|
||||||
|
"@sttracker/plugin-sdk": "workspace:*",
|
||||||
|
"kysely": "^0.29.7",
|
||||||
|
"zod": "^4.6.7"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,49 @@
|
|||||||
|
import { apiHandler, errorResponse, type AppDeps, type RouteDef } from "./http.ts";
|
||||||
|
import { attachmentRoutes } from "./routes/attachments.ts";
|
||||||
|
import { authRoutes } from "./routes/auth.ts";
|
||||||
|
import { changeRoutes } from "./routes/changes.ts";
|
||||||
|
import { copyRoutes } from "./routes/copies.ts";
|
||||||
|
import { customFieldRoutes } from "./routes/custom-fields.ts";
|
||||||
|
import { engagementRoutes } from "./routes/engagements.ts";
|
||||||
|
import { entryRoutes } from "./routes/entries.ts";
|
||||||
|
import { exportRoutes } from "./routes/export.ts";
|
||||||
|
import { listRoutes } from "./routes/lists.ts";
|
||||||
|
import { metaRoutes } from "./routes/meta.ts";
|
||||||
|
import { sourceRoutes } from "./routes/sources.ts";
|
||||||
|
import { workRoutes } from "./routes/works.ts";
|
||||||
|
|
||||||
|
export interface App {
|
||||||
|
/** Handle `/api/v1/*` and `/ingest/*`. Anything else is a JSON 404 (the web app is served by `Bun.serve` routes). */
|
||||||
|
fetch(req: Request, server?: { requestIP(req: Request): { address: string } | null }): Promise<Response>;
|
||||||
|
routes: RouteDef[];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The HTTP API, testable without a listening socket. */
|
||||||
|
export function createApp(deps: AppDeps): App {
|
||||||
|
const routes: RouteDef[] = [
|
||||||
|
...authRoutes,
|
||||||
|
...entryRoutes,
|
||||||
|
...engagementRoutes,
|
||||||
|
...workRoutes,
|
||||||
|
...listRoutes,
|
||||||
|
...copyRoutes,
|
||||||
|
...customFieldRoutes,
|
||||||
|
...attachmentRoutes,
|
||||||
|
...sourceRoutes,
|
||||||
|
...changeRoutes,
|
||||||
|
...exportRoutes,
|
||||||
|
];
|
||||||
|
routes.push(...metaRoutes(() => routes));
|
||||||
|
const api = apiHandler(routes, deps);
|
||||||
|
|
||||||
|
return {
|
||||||
|
routes,
|
||||||
|
async fetch(req, server) {
|
||||||
|
const { pathname } = new URL(req.url);
|
||||||
|
if (pathname.startsWith("/api/v1/")) return api(req, server?.requestIP(req)?.address ?? "unknown");
|
||||||
|
// Phase 1: the ingest mount exists but no plugin has the `ingest` capability.
|
||||||
|
if (pathname.startsWith("/ingest/")) return errorResponse(404, "not_found", "No ingest endpoint for this plugin.");
|
||||||
|
return errorResponse(404, "not_found", "No such endpoint.");
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
import { enqueue } from "@sttracker/core/jobs";
|
||||||
|
import type { Db } from "@sttracker/db/db";
|
||||||
|
|
||||||
|
/** The next 03:00 local time strictly after `now`. */
|
||||||
|
export function nextBackupAt(now: Date = new Date()): Date {
|
||||||
|
const d = new Date(now);
|
||||||
|
d.setHours(3, 0, 0, 0);
|
||||||
|
if (d.getTime() <= now.getTime()) d.setDate(d.getDate() + 1);
|
||||||
|
return d;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Queue the daily `backup` job for the next 03:00 unless one is already queued or running (lockKey "backup").
|
||||||
|
* Returns the job id, or null when one is pending.
|
||||||
|
*/
|
||||||
|
export function ensureBackupScheduled(db: Db, now: Date = new Date()): Promise<string | null> {
|
||||||
|
return enqueue(db, "backup", {}, { lockKey: "backup", runAt: nextBackupAt(now).toISOString() });
|
||||||
|
}
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
export interface Config {
|
||||||
|
databaseUrl: string;
|
||||||
|
dataDir: string;
|
||||||
|
secret: string;
|
||||||
|
publicUrl: string | undefined;
|
||||||
|
port: number;
|
||||||
|
pluginDirs: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export const MISSING_SECRET = "Set STTRACKER_SECRET to a long random string. It encrypts source credentials.";
|
||||||
|
|
||||||
|
/** Read the server config from the environment. Throws when `STTRACKER_SECRET` is missing. */
|
||||||
|
export function loadConfig(env: Record<string, string | undefined> = process.env): Config {
|
||||||
|
const secret = env.STTRACKER_SECRET;
|
||||||
|
if (!secret) throw new Error(MISSING_SECRET);
|
||||||
|
const dataDir = env.DATA_DIR || "./data";
|
||||||
|
const port = Number(env.PORT || 3000);
|
||||||
|
if (!Number.isInteger(port) || port < 0 || port > 65535) throw new Error(`PORT must be a port number, got "${env.PORT}"`);
|
||||||
|
return {
|
||||||
|
databaseUrl: env.DATABASE_URL || `sqlite://${dataDir}/sttracker.db`,
|
||||||
|
dataDir,
|
||||||
|
secret,
|
||||||
|
publicUrl: env.PUBLIC_URL || undefined,
|
||||||
|
port,
|
||||||
|
pluginDirs: (env.PLUGIN_DIRS || `plugins,${dataDir}/plugins`)
|
||||||
|
.split(",")
|
||||||
|
.map((d) => d.trim())
|
||||||
|
.filter((d) => d !== ""),
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,316 @@
|
|||||||
|
import { getSessionUser, verifyAppToken, type User } from "@sttracker/core/auth";
|
||||||
|
import { AuthError, ImportError, NotFoundError, ValidationError } from "@sttracker/core/errors";
|
||||||
|
import { isUniqueViolation } from "@sttracker/core/matching";
|
||||||
|
import type { PluginRegistry } from "@sttracker/core/registry";
|
||||||
|
import type { Db } from "@sttracker/db/db";
|
||||||
|
import type { TokenScope } from "@sttracker/db/schema";
|
||||||
|
import { z } from "zod";
|
||||||
|
|
||||||
|
export type AuthMode = "none" | "session" | "token:read" | "token:write" | "any:read" | "any:write";
|
||||||
|
export type Method = "GET" | "POST" | "PATCH" | "DELETE";
|
||||||
|
|
||||||
|
export interface AppDeps {
|
||||||
|
db: Db;
|
||||||
|
registry: PluginRegistry;
|
||||||
|
secret: string;
|
||||||
|
dataDir: string;
|
||||||
|
/** When it starts with `https:`, the session cookie is `Secure`. */
|
||||||
|
publicUrl?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const SESSION_COOKIE = "stt_session";
|
||||||
|
export const CSRF_HEADER = "x-requested-with";
|
||||||
|
export const CSRF_VALUE = "sttracker";
|
||||||
|
export const MAX_JSON_BYTES = 1024 * 1024;
|
||||||
|
export const MAX_UPLOAD_BYTES = 50 * 1024 * 1024;
|
||||||
|
const SESSION_MAX_AGE_S = 30 * 24 * 60 * 60;
|
||||||
|
|
||||||
|
export interface Ctx<B, Q> {
|
||||||
|
req: Request;
|
||||||
|
url: URL;
|
||||||
|
params: Record<string, string>;
|
||||||
|
body: B;
|
||||||
|
query: Q;
|
||||||
|
/** The authenticated user. Throws on `auth: "none"` routes, which have none. */
|
||||||
|
user: User;
|
||||||
|
/** The session token, when the request was authenticated by cookie. */
|
||||||
|
sessionToken: string | null;
|
||||||
|
/** Parsed multipart body, for `multipart: true` routes. */
|
||||||
|
form: FormData;
|
||||||
|
deps: AppDeps;
|
||||||
|
ip: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RouteDef<B = any, Q = any> {
|
||||||
|
method: Method;
|
||||||
|
/** Relative to `/api/v1`, with `:param` segments. */
|
||||||
|
path: string;
|
||||||
|
summary: string;
|
||||||
|
auth: AuthMode;
|
||||||
|
body?: z.ZodType<B>;
|
||||||
|
query?: z.ZodType<Q>;
|
||||||
|
/** Parse the body as multipart form data (upload limit) instead of JSON. */
|
||||||
|
multipart?: boolean;
|
||||||
|
/** Rate limit per client IP (login, register). */
|
||||||
|
rateLimited?: boolean;
|
||||||
|
/** Only admins may call it (403 otherwise). */
|
||||||
|
admin?: boolean;
|
||||||
|
handler(ctx: Ctx<B, Q>): Promise<unknown>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Declare a route; returns it unchanged with the handler's body and query types inferred from the schemas. */
|
||||||
|
export function route<BS extends z.ZodType = z.ZodUnknown, QS extends z.ZodType = z.ZodUnknown>(
|
||||||
|
def: Omit<RouteDef<z.output<BS>, z.output<QS>>, "body" | "query"> & { body?: BS; query?: QS },
|
||||||
|
): RouteDef {
|
||||||
|
return def as RouteDef;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Throw from a handler to answer with a specific status and error code. */
|
||||||
|
export class HttpError extends Error {
|
||||||
|
constructor(
|
||||||
|
readonly status: number,
|
||||||
|
readonly code: string,
|
||||||
|
message: string,
|
||||||
|
) {
|
||||||
|
super(message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function json(data: unknown, status = 200, headers: Record<string, string> = {}): Response {
|
||||||
|
return new Response(JSON.stringify(data), { status, headers: { "content-type": "application/json; charset=utf-8", ...headers } });
|
||||||
|
}
|
||||||
|
|
||||||
|
export function errorResponse(status: number, code: string, message: string, headers: Record<string, string> = {}): Response {
|
||||||
|
return json({ error: { code, message } }, status, headers);
|
||||||
|
}
|
||||||
|
|
||||||
|
export const noContent = (): Response => new Response(null, { status: 204 });
|
||||||
|
|
||||||
|
const AUTH_STATUS: Record<string, [number, string, string]> = {
|
||||||
|
invalid_credentials: [401, "unauthenticated", "Wrong username or password."],
|
||||||
|
unauthenticated: [401, "unauthenticated", "Sign in first."],
|
||||||
|
forbidden: [403, "forbidden", "You are not allowed to do that."],
|
||||||
|
invite_required: [400, "invite_required", "Registration needs a valid invite code."],
|
||||||
|
username_taken: [400, "username_taken", "That username is taken."],
|
||||||
|
invalid_username: [400, "invalid_username", "Usernames are 2–32 characters: lowercase letters, digits and underscores."],
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Map a thrown error to the JSON error response (ruling 5). Unknown errors are logged and hidden. */
|
||||||
|
export function toErrorResponse(e: unknown): Response {
|
||||||
|
if (e instanceof HttpError) return errorResponse(e.status, e.code, e.message);
|
||||||
|
if (e instanceof z.ZodError) {
|
||||||
|
const msg = e.issues.map((i) => `${i.path.length ? i.path.join(".") : "body"}: ${i.message}`).join("; ");
|
||||||
|
return errorResponse(400, "validation", msg);
|
||||||
|
}
|
||||||
|
if (e instanceof ValidationError) return errorResponse(400, "validation", `${e.field}: ${e.reason}`);
|
||||||
|
if (e instanceof NotFoundError) return errorResponse(404, "not_found", e.message);
|
||||||
|
if (e instanceof AuthError) {
|
||||||
|
const [status, code, message] = AUTH_STATUS[e.code] ?? [403, "forbidden", "You are not allowed to do that."];
|
||||||
|
return errorResponse(status, code, message);
|
||||||
|
}
|
||||||
|
if (e instanceof ImportError) {
|
||||||
|
const status = e.code === "account_not_empty" || e.code === "conflict" ? 409 : 400;
|
||||||
|
return errorResponse(status, e.code, `Import failed: ${e.code}.`);
|
||||||
|
}
|
||||||
|
if (isUniqueViolation(e)) return errorResponse(409, "conflict", "That already exists.");
|
||||||
|
console.error("[api]", e);
|
||||||
|
return errorResponse(500, "internal", "Something went wrong on the server.");
|
||||||
|
}
|
||||||
|
|
||||||
|
export function sessionCookie(token: string, publicUrl: string | undefined): string {
|
||||||
|
const secure = publicUrl?.startsWith("https:") ? "; Secure" : "";
|
||||||
|
return `${SESSION_COOKIE}=${token}; Path=/; HttpOnly; SameSite=Lax; Max-Age=${SESSION_MAX_AGE_S}${secure}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function clearSessionCookie(publicUrl: string | undefined): string {
|
||||||
|
const secure = publicUrl?.startsWith("https:") ? "; Secure" : "";
|
||||||
|
return `${SESSION_COOKIE}=; Path=/; HttpOnly; SameSite=Lax; Max-Age=0${secure}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function readCookie(req: Request, name: string): string | null {
|
||||||
|
const header = req.headers.get("cookie");
|
||||||
|
if (!header) return null;
|
||||||
|
for (const part of header.split(";")) {
|
||||||
|
const i = part.indexOf("=");
|
||||||
|
if (i > 0 && part.slice(0, i).trim() === name) return decodeURIComponent(part.slice(i + 1).trim()) || null;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Read a request body, failing with 413 once it exceeds `max` bytes (whatever Content-Length claims). */
|
||||||
|
async function readLimited(req: Request, max: number): Promise<Uint8Array<ArrayBuffer>> {
|
||||||
|
const tooLarge = () => new HttpError(413, "validation", `Request body is too large (max ${max / (1024 * 1024)} MB).`);
|
||||||
|
const declared = Number(req.headers.get("content-length") ?? NaN);
|
||||||
|
if (declared > max) throw tooLarge();
|
||||||
|
if (!req.body) return new Uint8Array();
|
||||||
|
const reader = req.body.getReader();
|
||||||
|
const chunks: Uint8Array[] = [];
|
||||||
|
let total = 0;
|
||||||
|
for (;;) {
|
||||||
|
const { done, value } = await reader.read();
|
||||||
|
if (done) break;
|
||||||
|
total += value.byteLength;
|
||||||
|
if (total > max) {
|
||||||
|
await reader.cancel();
|
||||||
|
throw tooLarge();
|
||||||
|
}
|
||||||
|
chunks.push(value);
|
||||||
|
}
|
||||||
|
const out = new Uint8Array(total);
|
||||||
|
let at = 0;
|
||||||
|
for (const c of chunks) {
|
||||||
|
out.set(c, at);
|
||||||
|
at += c.byteLength;
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Fixed-window-ish limiter: at most `limit` hits per `windowMs` per key. */
|
||||||
|
export function rateLimiter(limit: number, windowMs: number): (key: string) => boolean {
|
||||||
|
const hits = new Map<string, number[]>();
|
||||||
|
return (key) => {
|
||||||
|
const now = Date.now();
|
||||||
|
if (hits.size > 10_000) for (const [k, v] of hits) if (v.every((t) => now - t >= windowMs)) hits.delete(k);
|
||||||
|
const recent = (hits.get(key) ?? []).filter((t) => now - t < windowMs);
|
||||||
|
if (recent.length >= limit) {
|
||||||
|
hits.set(key, recent);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
recent.push(now);
|
||||||
|
hits.set(key, recent);
|
||||||
|
return true;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const SCOPES: TokenScope[] = ["read", "write", "ingest"];
|
||||||
|
|
||||||
|
/** Resolve the caller for `mode`. Returns the user and the session token used, if any. */
|
||||||
|
async function authenticate(req: Request, mode: AuthMode, db: Db): Promise<{ user: User | null; sessionToken: string | null }> {
|
||||||
|
if (mode === "none") return { user: null, sessionToken: null };
|
||||||
|
const authz = req.headers.get("authorization");
|
||||||
|
const bearer = authz?.startsWith("Bearer ") ? authz.slice(7).trim() : null;
|
||||||
|
const [kind, scopeName] = mode.split(":") as ["session" | "token" | "any", TokenScope | undefined];
|
||||||
|
|
||||||
|
if (kind !== "session" && bearer) {
|
||||||
|
const scope = scopeName!;
|
||||||
|
const user = await verifyAppToken(db, bearer, scope);
|
||||||
|
if (user) return { user, sessionToken: null };
|
||||||
|
for (const other of SCOPES) {
|
||||||
|
if (other !== scope && (await verifyAppToken(db, bearer, other))) {
|
||||||
|
throw new HttpError(403, "forbidden", `This app token lacks the '${scope}' scope.`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
throw new HttpError(401, "unauthenticated", "Invalid or revoked app token.");
|
||||||
|
}
|
||||||
|
if (kind === "token") throw new HttpError(401, "unauthenticated", "Send an app token: Authorization: Bearer <token>.");
|
||||||
|
|
||||||
|
const token = readCookie(req, SESSION_COOKIE);
|
||||||
|
const user = token ? await getSessionUser(db, token) : null;
|
||||||
|
if (!user) throw new HttpError(401, "unauthenticated", "Sign in first.");
|
||||||
|
// CSRF: browsers cannot set this header cross-site without a CORS preflight, which we never grant.
|
||||||
|
if (req.method !== "GET" && req.method !== "HEAD" && req.headers.get(CSRF_HEADER) !== CSRF_VALUE) {
|
||||||
|
throw new HttpError(403, "forbidden", `Cookie-authenticated requests must send the header X-Requested-With: ${CSRF_VALUE}.`);
|
||||||
|
}
|
||||||
|
return { user, sessionToken: token };
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Compiled {
|
||||||
|
def: RouteDef;
|
||||||
|
segments: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
function matchSegments(pattern: string[], parts: string[]): Record<string, string> | null {
|
||||||
|
if (pattern.length !== parts.length) return null;
|
||||||
|
const params: Record<string, string> = {};
|
||||||
|
for (let i = 0; i < pattern.length; i++) {
|
||||||
|
const p = pattern[i]!;
|
||||||
|
const v = parts[i]!;
|
||||||
|
if (p.startsWith(":")) {
|
||||||
|
if (v === "") return null;
|
||||||
|
try {
|
||||||
|
params[p.slice(1)] = decodeURIComponent(v);
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
} else if (p !== v) return null;
|
||||||
|
}
|
||||||
|
return params;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Build the `/api/v1/*` request handler over a route table. */
|
||||||
|
export function apiHandler(routes: RouteDef[], deps: AppDeps, prefix = "/api/v1/"): (req: Request, ip: string) => Promise<Response> {
|
||||||
|
const compiled: Compiled[] = routes.map((def) => ({ def, segments: def.path.split("/") }));
|
||||||
|
const limit = rateLimiter(10, 60_000);
|
||||||
|
|
||||||
|
return async (req, ip) => {
|
||||||
|
try {
|
||||||
|
const url = new URL(req.url);
|
||||||
|
const parts = url.pathname.slice(prefix.length).replace(/\/$/, "").split("/");
|
||||||
|
let found: { def: RouteDef; params: Record<string, string> } | null = null;
|
||||||
|
for (const c of compiled) {
|
||||||
|
if (c.def.method !== req.method) continue;
|
||||||
|
const params = matchSegments(c.segments, parts);
|
||||||
|
if (params) {
|
||||||
|
found = { def: c.def, params };
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!found) return errorResponse(404, "not_found", "No such endpoint.");
|
||||||
|
const { def, params } = found;
|
||||||
|
|
||||||
|
if (def.rateLimited && !limit(`${def.path}|${ip}`)) {
|
||||||
|
return errorResponse(429, "rate_limited", "Too many attempts. Wait a minute and try again.", { "retry-after": "60" });
|
||||||
|
}
|
||||||
|
const { user, sessionToken } = await authenticate(req, def.auth, deps.db);
|
||||||
|
if (def.admin && user?.role !== "admin") throw new HttpError(403, "forbidden", "Only an admin can do that.");
|
||||||
|
|
||||||
|
let body: unknown;
|
||||||
|
let form: FormData | undefined;
|
||||||
|
if (def.multipart) {
|
||||||
|
const type = req.headers.get("content-type") ?? "";
|
||||||
|
if (!type.startsWith("multipart/form-data")) throw new HttpError(400, "validation", "body: expected multipart/form-data");
|
||||||
|
const bytes = await readLimited(req, MAX_UPLOAD_BYTES);
|
||||||
|
try {
|
||||||
|
form = await new Response(bytes, { headers: { "content-type": type } }).formData();
|
||||||
|
} catch {
|
||||||
|
throw new HttpError(400, "validation", "body: malformed multipart form data");
|
||||||
|
}
|
||||||
|
} else if (def.body) {
|
||||||
|
const bytes = await readLimited(req, MAX_JSON_BYTES);
|
||||||
|
let raw: unknown;
|
||||||
|
if (bytes.byteLength > 0) {
|
||||||
|
try {
|
||||||
|
raw = JSON.parse(new TextDecoder().decode(bytes));
|
||||||
|
} catch {
|
||||||
|
throw new HttpError(400, "validation", "body: invalid JSON");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
body = def.body.parse(raw);
|
||||||
|
}
|
||||||
|
const query = def.query ? def.query.parse(Object.fromEntries(url.searchParams)) : undefined;
|
||||||
|
|
||||||
|
const ctx: Ctx<unknown, unknown> = {
|
||||||
|
req,
|
||||||
|
url,
|
||||||
|
params,
|
||||||
|
body,
|
||||||
|
query,
|
||||||
|
get user(): User {
|
||||||
|
if (!user) throw new Error(`route ${def.method} ${def.path} has no authenticated user`);
|
||||||
|
return user;
|
||||||
|
},
|
||||||
|
sessionToken,
|
||||||
|
get form(): FormData {
|
||||||
|
if (!form) throw new Error(`route ${def.method} ${def.path} is not multipart`);
|
||||||
|
return form;
|
||||||
|
},
|
||||||
|
deps,
|
||||||
|
ip,
|
||||||
|
};
|
||||||
|
const out = await def.handler(ctx);
|
||||||
|
return out instanceof Response ? out : json(out);
|
||||||
|
} catch (e) {
|
||||||
|
return toErrorResponse(e);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,66 @@
|
|||||||
|
import { mkdir } from "node:fs/promises";
|
||||||
|
import { join } from "node:path";
|
||||||
|
import { backupHandler } from "@sttracker/core/backup";
|
||||||
|
import { syncHandler } from "@sttracker/core/connections";
|
||||||
|
import { startScheduler, startWorker } from "@sttracker/core/jobs";
|
||||||
|
import { loadPlugins } from "@sttracker/core/registry";
|
||||||
|
import { createDb } from "@sttracker/db/db";
|
||||||
|
import { migrateToLatest } from "@sttracker/db/migrate";
|
||||||
|
import index from "../../web/index.html";
|
||||||
|
import { createApp } from "./app.ts";
|
||||||
|
import { ensureBackupScheduled } from "./boot.ts";
|
||||||
|
import { loadConfig } from "./config.ts";
|
||||||
|
|
||||||
|
const BACKUP_CHECK_MS = 60 * 60 * 1000;
|
||||||
|
|
||||||
|
const config = loadConfig();
|
||||||
|
await mkdir(join(config.dataDir, "plugins"), { recursive: true });
|
||||||
|
const db = await createDb(config.databaseUrl);
|
||||||
|
await migrateToLatest(db);
|
||||||
|
const registry = await loadPlugins(config.pluginDirs);
|
||||||
|
|
||||||
|
const ensureBackup = () => ensureBackupScheduled(db).catch((e) => console.error("[backup] scheduling failed", e));
|
||||||
|
const runBackup = backupHandler({ db, dataDir: config.dataDir });
|
||||||
|
const worker = startWorker(db, {
|
||||||
|
sync: syncHandler({ db, secret: config.secret, registry }),
|
||||||
|
backup: async () => {
|
||||||
|
try {
|
||||||
|
await runBackup();
|
||||||
|
} finally {
|
||||||
|
// The finishing job still holds lockKey "backup" until the worker marks it done; queue the next one shortly after.
|
||||||
|
setTimeout(() => void ensureBackup(), 5_000);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const scheduler = startScheduler(db);
|
||||||
|
await ensureBackup();
|
||||||
|
// Safety net: re-queue the daily backup if a run's follow-up was missed (e.g. it failed for good).
|
||||||
|
const backupTimer = setInterval(() => void ensureBackup(), BACKUP_CHECK_MS);
|
||||||
|
|
||||||
|
const app = createApp({ db, registry, secret: config.secret, dataDir: config.dataDir, publicUrl: config.publicUrl });
|
||||||
|
const server = Bun.serve({
|
||||||
|
port: config.port,
|
||||||
|
development: process.env.NODE_ENV !== "production",
|
||||||
|
routes: {
|
||||||
|
"/api/*": (req, srv) => app.fetch(req, srv),
|
||||||
|
"/ingest/*": (req, srv) => app.fetch(req, srv),
|
||||||
|
"/*": index,
|
||||||
|
},
|
||||||
|
fetch: (req, srv) => app.fetch(req, srv),
|
||||||
|
});
|
||||||
|
console.log(`STTracker listening on ${server.url} (${db.dialect}, ${registry.list().length} plugin(s))`);
|
||||||
|
|
||||||
|
let stopping = false;
|
||||||
|
async function shutdown(signal: string): Promise<void> {
|
||||||
|
if (stopping) return;
|
||||||
|
stopping = true;
|
||||||
|
console.log(`${signal}: shutting down`);
|
||||||
|
clearInterval(backupTimer);
|
||||||
|
scheduler.stop();
|
||||||
|
await server.stop();
|
||||||
|
await worker.stop();
|
||||||
|
await db.close();
|
||||||
|
process.exit(0);
|
||||||
|
}
|
||||||
|
process.on("SIGTERM", () => void shutdown("SIGTERM"));
|
||||||
|
process.on("SIGINT", () => void shutdown("SIGINT"));
|
||||||
@@ -0,0 +1,64 @@
|
|||||||
|
import { z } from "zod";
|
||||||
|
import type { RouteDef } from "./http.ts";
|
||||||
|
|
||||||
|
type JsonSchema = Record<string, unknown>;
|
||||||
|
|
||||||
|
const toSchema = (s: z.ZodType): JsonSchema => {
|
||||||
|
const { $schema: _, ...rest } = z.toJSONSchema(s, { io: "input", unrepresentable: "any" }) as JsonSchema;
|
||||||
|
return rest;
|
||||||
|
};
|
||||||
|
|
||||||
|
const SECURITY: Record<RouteDef["auth"], { [k: string]: string[] }[]> = {
|
||||||
|
none: [],
|
||||||
|
session: [{ session: [] }],
|
||||||
|
"token:read": [{ appToken: ["read"] }],
|
||||||
|
"token:write": [{ appToken: ["write"] }],
|
||||||
|
"any:read": [{ session: [] }, { appToken: ["read"] }],
|
||||||
|
"any:write": [{ session: [] }, { appToken: ["write"] }],
|
||||||
|
};
|
||||||
|
|
||||||
|
/** OpenAPI 3.1 document generated from the route table (paths, summaries, request schemas). */
|
||||||
|
export function buildOpenApi(routes: RouteDef[], prefix = "/api/v1"): JsonSchema {
|
||||||
|
const paths: Record<string, Record<string, JsonSchema>> = {};
|
||||||
|
for (const r of routes) {
|
||||||
|
const path = `${prefix}/${r.path.replace(/:(\w+)/g, "{$1}")}`;
|
||||||
|
const parameters: JsonSchema[] = [...r.path.matchAll(/:(\w+)/g)].map((m) => ({
|
||||||
|
name: m[1],
|
||||||
|
in: "path",
|
||||||
|
required: true,
|
||||||
|
schema: { type: "string" },
|
||||||
|
}));
|
||||||
|
if (r.query) {
|
||||||
|
const q = toSchema(r.query) as { properties?: Record<string, JsonSchema>; required?: string[] };
|
||||||
|
for (const [name, schema] of Object.entries(q.properties ?? {})) {
|
||||||
|
parameters.push({ name, in: "query", required: q.required?.includes(name) ?? false, schema });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const op: JsonSchema = {
|
||||||
|
operationId: `${r.method.toLowerCase()}_${r.path.replace(/[^\w]+/g, "_")}`,
|
||||||
|
summary: r.summary,
|
||||||
|
security: SECURITY[r.auth],
|
||||||
|
responses: {
|
||||||
|
default: {
|
||||||
|
description: "Success, or an error: `{ error: { code, message } }`.",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
if (parameters.length > 0) op.parameters = parameters;
|
||||||
|
if (r.body) op.requestBody = { required: !r.body.safeParse(undefined).success, content: { "application/json": { schema: toSchema(r.body) } } };
|
||||||
|
if (r.multipart) op.requestBody = { required: true, content: { "multipart/form-data": { schema: { type: "object" } } } };
|
||||||
|
(paths[path] ??= {})[r.method.toLowerCase()] = op;
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
openapi: "3.1.0",
|
||||||
|
info: { title: "STTracker API", version: "1" },
|
||||||
|
servers: [{ url: "/" }],
|
||||||
|
components: {
|
||||||
|
securitySchemes: {
|
||||||
|
session: { type: "apiKey", in: "cookie", name: "stt_session", description: "Non-GET requests also need X-Requested-With: sttracker." },
|
||||||
|
appToken: { type: "http", scheme: "bearer", description: "App token with scopes read, write or ingest." },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
paths,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,76 @@
|
|||||||
|
import { listAttachmentLinks, readAttachment, requireLinkTarget, storeAttachment, unlinkAttachment } from "@sttracker/core/attachments";
|
||||||
|
import { NotFoundError } from "@sttracker/core/errors";
|
||||||
|
import type { AttachmentEntity } from "@sttracker/db/schema";
|
||||||
|
import { z } from "zod";
|
||||||
|
import { json, noContent, route } from "../http.ts";
|
||||||
|
import { formFile } from "./form.ts";
|
||||||
|
|
||||||
|
const fields = z.object({
|
||||||
|
entity: z.enum(["entry", "engagement", "copy", "list", "work"]),
|
||||||
|
entityId: z.string().min(1),
|
||||||
|
caption: z.string().max(10_000).optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
/** A filename safe inside a quoted Content-Disposition value. */
|
||||||
|
export function safeFilename(name: string): string {
|
||||||
|
const cleaned = name.replace(/[^\w.\- ]/g, "_").slice(0, 200).trim();
|
||||||
|
return cleaned === "" ? "file" : cleaned;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const attachmentRoutes = [
|
||||||
|
route({
|
||||||
|
method: "POST",
|
||||||
|
path: "attachments",
|
||||||
|
summary: "Upload a file (multipart: file, entity, entityId, caption) and link it to one of your records.",
|
||||||
|
auth: "any:write",
|
||||||
|
multipart: true,
|
||||||
|
handler: async ({ deps, user, form }) => {
|
||||||
|
const file = formFile(form);
|
||||||
|
const f = fields.parse({
|
||||||
|
entity: form.get("entity") ?? undefined,
|
||||||
|
entityId: form.get("entityId") ?? undefined,
|
||||||
|
caption: form.get("caption") ?? undefined,
|
||||||
|
});
|
||||||
|
await requireLinkTarget(deps.db, user.id, f.entity as AttachmentEntity, f.entityId);
|
||||||
|
const att = await storeAttachment(
|
||||||
|
deps.db,
|
||||||
|
deps.dataDir,
|
||||||
|
user.id,
|
||||||
|
{ bytes: new Uint8Array(await file.arrayBuffer()), mime: file.type || "application/octet-stream", filename: file.name, caption: f.caption },
|
||||||
|
{ entity: f.entity as AttachmentEntity, entityId: f.entityId },
|
||||||
|
);
|
||||||
|
return json({ ...att, links: await listAttachmentLinks(deps.db, user.id, att.id) }, 201);
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
route({
|
||||||
|
method: "GET",
|
||||||
|
path: "attachments/:id",
|
||||||
|
summary: "Download an attachment's file.",
|
||||||
|
auth: "any:read",
|
||||||
|
handler: async ({ deps, user, params }) => {
|
||||||
|
const a = await readAttachment(deps.db, deps.dataDir, user.id, params.id!);
|
||||||
|
if (!a || !(await a.file.exists())) throw new NotFoundError("attachment");
|
||||||
|
return new Response(a.file, {
|
||||||
|
headers: {
|
||||||
|
"content-type": a.mime,
|
||||||
|
"x-content-type-options": "nosniff",
|
||||||
|
"content-disposition": `inline; filename="${safeFilename(a.filename)}"`,
|
||||||
|
"content-security-policy": "default-src 'none'; sandbox",
|
||||||
|
"cache-control": "private, max-age=3600",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
route({
|
||||||
|
method: "DELETE",
|
||||||
|
path: "attachments/:id/links/:linkId",
|
||||||
|
summary: "Unlink an attachment; it is deleted when no links remain.",
|
||||||
|
auth: "any:write",
|
||||||
|
handler: async ({ deps, user, params }) => {
|
||||||
|
const link = (await listAttachmentLinks(deps.db, user.id, params.id!)).find((l) => l.id === params.linkId);
|
||||||
|
if (!link) throw new NotFoundError("attachment link");
|
||||||
|
await unlinkAttachment(deps.db, deps.dataDir, user.id, params.id!, link.entity, link.entityId);
|
||||||
|
return noContent();
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
];
|
||||||
@@ -0,0 +1,76 @@
|
|||||||
|
import { createAppToken, createInvite, listAppTokens, login, logout, registerUser, revokeAppToken } from "@sttracker/core/auth";
|
||||||
|
import { z } from "zod";
|
||||||
|
import { clearSessionCookie, json, noContent, route, sessionCookie } from "../http.ts";
|
||||||
|
|
||||||
|
const credentials = z.object({
|
||||||
|
username: z.string().min(1).max(64),
|
||||||
|
password: z.string().min(8).max(1024),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const authRoutes = [
|
||||||
|
route({
|
||||||
|
method: "POST",
|
||||||
|
path: "auth/register",
|
||||||
|
summary: "Create an account. The first user becomes admin; later ones need an invite code.",
|
||||||
|
auth: "none",
|
||||||
|
rateLimited: true,
|
||||||
|
body: credentials.extend({ displayName: z.string().min(1).max(100).optional(), inviteCode: z.string().max(200).optional() }).strict(),
|
||||||
|
handler: async ({ deps, body }) => json(await registerUser(deps.db, body), 201),
|
||||||
|
}),
|
||||||
|
route({
|
||||||
|
method: "POST",
|
||||||
|
path: "auth/login",
|
||||||
|
summary: "Sign in; sets the stt_session cookie.",
|
||||||
|
auth: "none",
|
||||||
|
rateLimited: true,
|
||||||
|
body: z.object({ username: z.string().max(64), password: z.string().max(1024) }).strict(),
|
||||||
|
handler: async ({ deps, body }) => {
|
||||||
|
const { user, sessionToken } = await login(deps.db, body.username, body.password);
|
||||||
|
return json(user, 200, { "set-cookie": sessionCookie(sessionToken, deps.publicUrl) });
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
route({
|
||||||
|
method: "POST",
|
||||||
|
path: "auth/logout",
|
||||||
|
summary: "End the current session and clear the cookie.",
|
||||||
|
auth: "session",
|
||||||
|
handler: async ({ deps, sessionToken }) => {
|
||||||
|
if (sessionToken) await logout(deps.db, sessionToken);
|
||||||
|
return new Response(null, { status: 204, headers: { "set-cookie": clearSessionCookie(deps.publicUrl) } });
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
route({ method: "GET", path: "me", summary: "The signed-in user.", auth: "any:read", handler: async ({ user }) => user }),
|
||||||
|
route({
|
||||||
|
method: "GET",
|
||||||
|
path: "me/tokens",
|
||||||
|
summary: "List your app tokens (metadata only).",
|
||||||
|
auth: "session",
|
||||||
|
handler: async ({ deps, user }) => listAppTokens(deps.db, user.id),
|
||||||
|
}),
|
||||||
|
route({
|
||||||
|
method: "POST",
|
||||||
|
path: "me/tokens",
|
||||||
|
summary: "Create an app token. The token is returned once.",
|
||||||
|
auth: "session",
|
||||||
|
body: z.object({ name: z.string().min(1).max(100), scopes: z.array(z.enum(["read", "write", "ingest"])).min(1) }).strict(),
|
||||||
|
handler: async ({ deps, user, body }) => json(await createAppToken(deps.db, user.id, body.name, [...new Set(body.scopes)]), 201),
|
||||||
|
}),
|
||||||
|
route({
|
||||||
|
method: "DELETE",
|
||||||
|
path: "me/tokens/:id",
|
||||||
|
summary: "Revoke an app token.",
|
||||||
|
auth: "session",
|
||||||
|
handler: async ({ deps, user, params }) => {
|
||||||
|
await revokeAppToken(deps.db, user.id, params.id!);
|
||||||
|
return noContent();
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
route({
|
||||||
|
method: "POST",
|
||||||
|
path: "admin/invites",
|
||||||
|
summary: "Create an invite code (admin). The code is returned once.",
|
||||||
|
auth: "session",
|
||||||
|
body: z.object({ ttlDays: z.number().int().min(1).max(365).optional() }).strict().optional(),
|
||||||
|
handler: async ({ deps, user, body }) => json({ code: await createInvite(deps.db, user.id, body?.ttlDays) }, 201),
|
||||||
|
}),
|
||||||
|
];
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
import { listChanges, resolveChange } from "@sttracker/core/sync/changes";
|
||||||
|
import { z } from "zod";
|
||||||
|
import { noContent, route } from "../http.ts";
|
||||||
|
import { qInt } from "./schemas.ts";
|
||||||
|
|
||||||
|
export const changeRoutes = [
|
||||||
|
route({
|
||||||
|
method: "GET",
|
||||||
|
path: "changes",
|
||||||
|
summary: "The Changes inbox (pending by default; status=all for everything).",
|
||||||
|
auth: "any:read",
|
||||||
|
query: z.object({
|
||||||
|
status: z.enum(["pending", "all"]).optional(),
|
||||||
|
connectionId: z.string().optional(),
|
||||||
|
limit: qInt.optional(),
|
||||||
|
cursor: z.string().optional(),
|
||||||
|
}),
|
||||||
|
handler: async ({ deps, user, query }) => listChanges(deps.db, user.id, query),
|
||||||
|
}),
|
||||||
|
route({
|
||||||
|
method: "POST",
|
||||||
|
path: "changes/:id/resolve",
|
||||||
|
summary: "Resolve a pending change: accept, ignore or keep_mine.",
|
||||||
|
auth: "any:write",
|
||||||
|
body: z.object({ action: z.enum(["accept", "ignore", "keep_mine"]) }).strict(),
|
||||||
|
handler: async ({ deps, user, params, body }) => {
|
||||||
|
await resolveChange(deps.db, user.id, params.id!, body.action);
|
||||||
|
return noContent();
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
];
|
||||||
@@ -0,0 +1,58 @@
|
|||||||
|
import { createCopy, listCopies, softDeleteCopy, updateCopy } from "@sttracker/core/copies";
|
||||||
|
import { z } from "zod";
|
||||||
|
import { json, noContent, route } from "../http.ts";
|
||||||
|
import { customFields, partialDate, text } from "./schemas.ts";
|
||||||
|
|
||||||
|
const copyPatch = z
|
||||||
|
.object({
|
||||||
|
workId: z.string().min(1).nullable(),
|
||||||
|
titleHint: z.string().max(1000).nullable(),
|
||||||
|
format: z.string().max(200).nullable(),
|
||||||
|
acquiredFrom: z.string().max(500).nullable(),
|
||||||
|
acquiredAt: partialDate.nullable(),
|
||||||
|
priceCents: z.number().int().nullable(),
|
||||||
|
currency: z.string().length(3).nullable(),
|
||||||
|
notes: text().nullable(),
|
||||||
|
customFields,
|
||||||
|
sourceRef: z.string().max(500).nullable(),
|
||||||
|
})
|
||||||
|
.partial()
|
||||||
|
.strict();
|
||||||
|
type CopyArg = Parameters<typeof updateCopy>[3];
|
||||||
|
|
||||||
|
export const copyRoutes = [
|
||||||
|
route({
|
||||||
|
method: "GET",
|
||||||
|
path: "copies",
|
||||||
|
summary: "Your owned copies (optionally of one work).",
|
||||||
|
auth: "any:read",
|
||||||
|
query: z.object({ workId: z.string().optional() }),
|
||||||
|
handler: async ({ deps, user, query }) => listCopies(deps.db, user.id, query),
|
||||||
|
}),
|
||||||
|
route({
|
||||||
|
method: "POST",
|
||||||
|
path: "copies",
|
||||||
|
summary: "Record an owned copy.",
|
||||||
|
auth: "any:write",
|
||||||
|
body: copyPatch,
|
||||||
|
handler: async ({ deps, user, body }) => json(await createCopy(deps.db, user.id, body as CopyArg), 201),
|
||||||
|
}),
|
||||||
|
route({
|
||||||
|
method: "PATCH",
|
||||||
|
path: "copies/:id",
|
||||||
|
summary: "Edit an owned copy.",
|
||||||
|
auth: "any:write",
|
||||||
|
body: copyPatch,
|
||||||
|
handler: async ({ deps, user, params, body }) => updateCopy(deps.db, user.id, params.id!, body as CopyArg),
|
||||||
|
}),
|
||||||
|
route({
|
||||||
|
method: "DELETE",
|
||||||
|
path: "copies/:id",
|
||||||
|
summary: "Soft-delete an owned copy.",
|
||||||
|
auth: "any:write",
|
||||||
|
handler: async ({ deps, user, params }) => {
|
||||||
|
await softDeleteCopy(deps.db, user.id, params.id!);
|
||||||
|
return noContent();
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
];
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
import { CUSTOM_FIELD_TYPES, defineCustomField, listCustomFields } from "@sttracker/core/custom-fields";
|
||||||
|
import { z } from "zod";
|
||||||
|
import { json, route } from "../http.ts";
|
||||||
|
|
||||||
|
const level = z.enum(["entry", "engagement", "copy"]);
|
||||||
|
|
||||||
|
export const customFieldRoutes = [
|
||||||
|
route({
|
||||||
|
method: "GET",
|
||||||
|
path: "custom-fields",
|
||||||
|
summary: "Your custom field definitions.",
|
||||||
|
auth: "any:read",
|
||||||
|
query: z.object({ mediaType: z.string().optional(), level: level.optional() }),
|
||||||
|
handler: async ({ deps, user, query }) => listCustomFields(deps.db, user.id, query.mediaType, query.level),
|
||||||
|
}),
|
||||||
|
route({
|
||||||
|
method: "POST",
|
||||||
|
path: "custom-fields",
|
||||||
|
summary: "Define a custom field.",
|
||||||
|
auth: "any:write",
|
||||||
|
body: z
|
||||||
|
.object({
|
||||||
|
name: z.string().min(1).max(200),
|
||||||
|
type: z.enum(CUSTOM_FIELD_TYPES),
|
||||||
|
options: z.array(z.string().max(200)).max(500).optional(),
|
||||||
|
appliesTo: z.union([z.literal("all"), z.array(z.string().max(50))]).optional(),
|
||||||
|
level: level.optional(),
|
||||||
|
})
|
||||||
|
.strict(),
|
||||||
|
handler: async ({ deps, user, body }) => json(await defineCustomField(deps.db, user.id, body), 201),
|
||||||
|
}),
|
||||||
|
];
|
||||||
@@ -0,0 +1,55 @@
|
|||||||
|
import { addEngagement, listEngagements, softDeleteEngagement, updateEngagement } from "@sttracker/core/engagements";
|
||||||
|
import { z } from "zod";
|
||||||
|
import { json, noContent, route } from "../http.ts";
|
||||||
|
import { customFields, partialDate, rating, text } from "./schemas.ts";
|
||||||
|
|
||||||
|
const engagementPatch = z
|
||||||
|
.object({
|
||||||
|
startedAt: partialDate.nullable(),
|
||||||
|
finishedAt: partialDate.nullable(),
|
||||||
|
progress: z.record(z.string(), z.union([z.number(), z.string().max(200)])),
|
||||||
|
platform: z.string().max(200).nullable(),
|
||||||
|
rating,
|
||||||
|
notes: text().nullable(),
|
||||||
|
customFields,
|
||||||
|
})
|
||||||
|
.partial()
|
||||||
|
.strict();
|
||||||
|
|
||||||
|
export const engagementRoutes = [
|
||||||
|
route({
|
||||||
|
method: "GET",
|
||||||
|
path: "entries/:id/engagements",
|
||||||
|
summary: "Reads, watches or plays of an entry.",
|
||||||
|
auth: "any:read",
|
||||||
|
handler: async ({ deps, user, params }) => listEngagements(deps.db, user.id, params.id!),
|
||||||
|
}),
|
||||||
|
route({
|
||||||
|
method: "POST",
|
||||||
|
path: "entries/:id/engagements",
|
||||||
|
summary: "Log a read, watch or play.",
|
||||||
|
auth: "any:write",
|
||||||
|
body: engagementPatch,
|
||||||
|
handler: async ({ deps, user, params, body }) =>
|
||||||
|
json(await addEngagement(deps.db, user.id, params.id!, body as Parameters<typeof addEngagement>[3], "user"), 201),
|
||||||
|
}),
|
||||||
|
route({
|
||||||
|
method: "PATCH",
|
||||||
|
path: "engagements/:id",
|
||||||
|
summary: "Edit an engagement.",
|
||||||
|
auth: "any:write",
|
||||||
|
body: engagementPatch,
|
||||||
|
handler: async ({ deps, user, params, body }) =>
|
||||||
|
updateEngagement(deps.db, user.id, params.id!, body as Parameters<typeof updateEngagement>[3], "user"),
|
||||||
|
}),
|
||||||
|
route({
|
||||||
|
method: "DELETE",
|
||||||
|
path: "engagements/:id",
|
||||||
|
summary: "Soft-delete an engagement.",
|
||||||
|
auth: "any:write",
|
||||||
|
handler: async ({ deps, user, params }) => {
|
||||||
|
await softDeleteEngagement(deps.db, user.id, params.id!, "user");
|
||||||
|
return noContent();
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
];
|
||||||
@@ -0,0 +1,107 @@
|
|||||||
|
import { getEntry, listEntries, moveEntryToWork, quickAdd, softDeleteEntry, upsertEntry } from "@sttracker/core/entries";
|
||||||
|
import { getHistory, restoreVersion } from "@sttracker/core/history";
|
||||||
|
import { z } from "zod";
|
||||||
|
import { json, noContent, route } from "../http.ts";
|
||||||
|
import { customFields, mediaType, qBool, qInt, rating, status, text, visibility } from "./schemas.ts";
|
||||||
|
|
||||||
|
const entryPatch = z
|
||||||
|
.object({
|
||||||
|
status,
|
||||||
|
rating,
|
||||||
|
review: text().nullable(),
|
||||||
|
privateNotes: text().nullable(),
|
||||||
|
favorite: z.boolean(),
|
||||||
|
tags: z.array(z.string().max(100)).max(200),
|
||||||
|
customFields,
|
||||||
|
visibility,
|
||||||
|
overrides: z.object({ title: z.string().max(1000), coverUrl: z.string().max(2000), year: z.number().int().nullable() }).partial().strict(),
|
||||||
|
mediaTypeOverride: mediaType.nullable(),
|
||||||
|
})
|
||||||
|
.partial()
|
||||||
|
.strict();
|
||||||
|
|
||||||
|
export const entryRoutes = [
|
||||||
|
route({
|
||||||
|
method: "GET",
|
||||||
|
path: "entries",
|
||||||
|
summary: "List your library entries (filter, sort, cursor pagination).",
|
||||||
|
auth: "any:read",
|
||||||
|
query: z.object({
|
||||||
|
mediaType: z.string().optional(),
|
||||||
|
status: status.optional(),
|
||||||
|
tag: z.string().optional(),
|
||||||
|
draft: qBool.optional(),
|
||||||
|
q: z.string().max(500).optional(),
|
||||||
|
parentId: z.string().optional(),
|
||||||
|
sort: z.enum(["updated", "title", "rating"]).optional(),
|
||||||
|
limit: qInt.optional(),
|
||||||
|
cursor: z.string().optional(),
|
||||||
|
}),
|
||||||
|
handler: async ({ deps, user, query }) => listEntries(deps.db, user.id, query as Parameters<typeof listEntries>[2]),
|
||||||
|
}),
|
||||||
|
route({
|
||||||
|
method: "POST",
|
||||||
|
path: "entries/quick",
|
||||||
|
summary: "Quick-add an entry by title (creates a private draft work).",
|
||||||
|
auth: "any:write",
|
||||||
|
body: z.object({ title: z.string().min(1).max(1000), mediaType: mediaType.optional() }).strict(),
|
||||||
|
handler: async ({ deps, user, body }) => json(await quickAdd(deps.db, user.id, body), 201),
|
||||||
|
}),
|
||||||
|
route({
|
||||||
|
method: "GET",
|
||||||
|
path: "entries/:id",
|
||||||
|
summary: "One entry with its work.",
|
||||||
|
auth: "any:read",
|
||||||
|
handler: async ({ deps, user, params }) => getEntry(deps.db, user.id, params.id!),
|
||||||
|
}),
|
||||||
|
route({
|
||||||
|
method: "PATCH",
|
||||||
|
path: "entries/:id",
|
||||||
|
summary: "Edit an entry. Edited fields become user-owned (syncs stop overwriting them).",
|
||||||
|
auth: "any:write",
|
||||||
|
body: entryPatch,
|
||||||
|
handler: async ({ deps, user, params, body }) => {
|
||||||
|
const cur = await getEntry(deps.db, user.id, params.id!);
|
||||||
|
await upsertEntry(deps.db, user.id, cur.workId, body as Parameters<typeof upsertEntry>[3], "user");
|
||||||
|
return getEntry(deps.db, user.id, params.id!);
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
route({
|
||||||
|
method: "DELETE",
|
||||||
|
path: "entries/:id",
|
||||||
|
summary: "Soft-delete an entry.",
|
||||||
|
auth: "any:write",
|
||||||
|
handler: async ({ deps, user, params }) => {
|
||||||
|
await softDeleteEntry(deps.db, user.id, params.id!, "user");
|
||||||
|
return noContent();
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
route({
|
||||||
|
method: "GET",
|
||||||
|
path: "entries/:id/history",
|
||||||
|
summary: "Change history of an entry, newest first.",
|
||||||
|
auth: "any:read",
|
||||||
|
handler: async ({ deps, user, params }) => getHistory(deps.db, user.id, "entry", params.id!),
|
||||||
|
}),
|
||||||
|
route({
|
||||||
|
method: "POST",
|
||||||
|
path: "history/:id/restore",
|
||||||
|
summary: "Restore the values a history row replaced.",
|
||||||
|
auth: "any:write",
|
||||||
|
handler: async ({ deps, user, params }) => {
|
||||||
|
await restoreVersion(deps.db, user.id, params.id!);
|
||||||
|
return noContent();
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
route({
|
||||||
|
method: "POST",
|
||||||
|
path: "entries/:id/move",
|
||||||
|
summary: "Move an entry to another work (for you only).",
|
||||||
|
auth: "any:write",
|
||||||
|
body: z.object({ workId: z.string().min(1) }).strict(),
|
||||||
|
handler: async ({ deps, user, params, body }) => {
|
||||||
|
const moved = await moveEntryToWork(deps.db, user.id, params.id!, body.workId, "user");
|
||||||
|
return getEntry(deps.db, user.id, moved.id);
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
];
|
||||||
@@ -0,0 +1,49 @@
|
|||||||
|
import { NotFoundError } from "@sttracker/core/errors";
|
||||||
|
import { exportUserBundle, exportUserCsv, importUserBundle } from "@sttracker/core/export";
|
||||||
|
import { z } from "zod";
|
||||||
|
import { route } from "../http.ts";
|
||||||
|
import { formFile } from "./form.ts";
|
||||||
|
|
||||||
|
const stamp = () => new Date().toISOString().slice(0, 10);
|
||||||
|
|
||||||
|
export const exportRoutes = [
|
||||||
|
route({
|
||||||
|
method: "GET",
|
||||||
|
path: "export/bundle",
|
||||||
|
summary: "Download your data as an sttracker-export/v1 zip bundle.",
|
||||||
|
auth: "any:read",
|
||||||
|
handler: async ({ deps, user }) =>
|
||||||
|
new Response((await exportUserBundle(deps.db, deps.dataDir, user.id)) as Uint8Array<ArrayBuffer>, {
|
||||||
|
headers: {
|
||||||
|
"content-type": "application/zip",
|
||||||
|
"content-disposition": `attachment; filename="sttracker-${user.username}-${stamp()}.zip"`,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
route({
|
||||||
|
method: "GET",
|
||||||
|
path: "export/csv",
|
||||||
|
summary: "Download one CSV file of your data (e.g. file=entries.csv).",
|
||||||
|
auth: "any:read",
|
||||||
|
query: z.object({ file: z.string().regex(/^[a-z_]+\.csv$/) }),
|
||||||
|
handler: async ({ deps, user, query }) => {
|
||||||
|
const files = await exportUserCsv(deps.db, user.id);
|
||||||
|
const body = files[query.file];
|
||||||
|
if (body === undefined) throw new NotFoundError("csv file");
|
||||||
|
return new Response(body, {
|
||||||
|
headers: { "content-type": "text/csv; charset=utf-8", "content-disposition": `attachment; filename="${query.file}"` },
|
||||||
|
});
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
route({
|
||||||
|
method: "POST",
|
||||||
|
path: "import/bundle",
|
||||||
|
summary: "Restore an export bundle (multipart: file) into your empty account.",
|
||||||
|
auth: "session",
|
||||||
|
multipart: true,
|
||||||
|
handler: async ({ deps, user, form }) => {
|
||||||
|
const file = formFile(form);
|
||||||
|
return importUserBundle(deps.db, deps.dataDir, user.id, new Uint8Array(await file.arrayBuffer()));
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
];
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
import { HttpError } from "../http.ts";
|
||||||
|
|
||||||
|
/** The `file` part of a multipart body, or a 400. */
|
||||||
|
export function formFile(form: FormData, field = "file"): File {
|
||||||
|
const f = form.get(field);
|
||||||
|
if (!(f instanceof File)) throw new HttpError(400, "validation", `${field}: a file is required`);
|
||||||
|
return f;
|
||||||
|
}
|
||||||
@@ -0,0 +1,84 @@
|
|||||||
|
import { addListItem, createList, getList, listLists, moveListItem, removeListItem, softDeleteList, updateList } from "@sttracker/core/lists";
|
||||||
|
import { z } from "zod";
|
||||||
|
import { json, noContent, route } from "../http.ts";
|
||||||
|
import { rating, text, visibility } from "./schemas.ts";
|
||||||
|
|
||||||
|
const kind = z.enum(["list", "grouping", "thread"]);
|
||||||
|
const listPatch = z
|
||||||
|
.object({ name: z.string().min(1).max(500), description: text(), kind, visibility, rating, review: text().nullable() })
|
||||||
|
.partial()
|
||||||
|
.strict();
|
||||||
|
type ListPatchArg = Parameters<typeof updateList>[3];
|
||||||
|
|
||||||
|
export const listRoutes = [
|
||||||
|
route({
|
||||||
|
method: "GET",
|
||||||
|
path: "lists",
|
||||||
|
summary: "Your lists.",
|
||||||
|
auth: "any:read",
|
||||||
|
query: z.object({ kind: kind.optional() }),
|
||||||
|
handler: async ({ deps, user, query }) => listLists(deps.db, user.id, query),
|
||||||
|
}),
|
||||||
|
route({
|
||||||
|
method: "POST",
|
||||||
|
path: "lists",
|
||||||
|
summary: "Create a list.",
|
||||||
|
auth: "any:write",
|
||||||
|
body: listPatch.required({ name: true }),
|
||||||
|
handler: async ({ deps, user, body }) => json(await createList(deps.db, user.id, body as ListPatchArg & { name: string }), 201),
|
||||||
|
}),
|
||||||
|
route({
|
||||||
|
method: "GET",
|
||||||
|
path: "lists/:id",
|
||||||
|
summary: "A list with its items.",
|
||||||
|
auth: "any:read",
|
||||||
|
handler: async ({ deps, user, params }) => getList(deps.db, user.id, params.id!),
|
||||||
|
}),
|
||||||
|
route({
|
||||||
|
method: "PATCH",
|
||||||
|
path: "lists/:id",
|
||||||
|
summary: "Edit a list.",
|
||||||
|
auth: "any:write",
|
||||||
|
body: listPatch,
|
||||||
|
handler: async ({ deps, user, params, body }) => updateList(deps.db, user.id, params.id!, body as ListPatchArg),
|
||||||
|
}),
|
||||||
|
route({
|
||||||
|
method: "DELETE",
|
||||||
|
path: "lists/:id",
|
||||||
|
summary: "Soft-delete a list.",
|
||||||
|
auth: "any:write",
|
||||||
|
handler: async ({ deps, user, params }) => {
|
||||||
|
await softDeleteList(deps.db, user.id, params.id!);
|
||||||
|
return noContent();
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
route({
|
||||||
|
method: "POST",
|
||||||
|
path: "lists/:id/items",
|
||||||
|
summary: "Add a work to a list.",
|
||||||
|
auth: "any:write",
|
||||||
|
body: z.object({ workId: z.string().min(1), note: text(10_000).nullable().optional(), position: z.number().int().min(0).optional() }).strict(),
|
||||||
|
handler: async ({ deps, user, params, body }) => json(await addListItem(deps.db, user.id, params.id!, body), 201),
|
||||||
|
}),
|
||||||
|
route({
|
||||||
|
method: "PATCH",
|
||||||
|
path: "list-items/:id",
|
||||||
|
summary: "Move a list item to a new 0-based position.",
|
||||||
|
auth: "any:write",
|
||||||
|
body: z.object({ position: z.number().int().min(0) }).strict(),
|
||||||
|
handler: async ({ deps, user, params, body }) => {
|
||||||
|
await moveListItem(deps.db, user.id, params.id!, body.position);
|
||||||
|
return noContent();
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
route({
|
||||||
|
method: "DELETE",
|
||||||
|
path: "list-items/:id",
|
||||||
|
summary: "Remove an item from a list.",
|
||||||
|
auth: "any:write",
|
||||||
|
handler: async ({ deps, user, params }) => {
|
||||||
|
await removeListItem(deps.db, user.id, params.id!);
|
||||||
|
return noContent();
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
];
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
import { route, type RouteDef } from "../http.ts";
|
||||||
|
import { buildOpenApi } from "../openapi.ts";
|
||||||
|
|
||||||
|
/** Routes that describe the API and instance. `table` returns the full route table (for OpenAPI). */
|
||||||
|
export function metaRoutes(table: () => RouteDef[]): RouteDef[] {
|
||||||
|
let doc: unknown;
|
||||||
|
return [
|
||||||
|
route({
|
||||||
|
method: "GET",
|
||||||
|
path: "openapi.json",
|
||||||
|
summary: "This API's OpenAPI document.",
|
||||||
|
auth: "none",
|
||||||
|
handler: async () => (doc ??= buildOpenApi(table())),
|
||||||
|
}),
|
||||||
|
route({
|
||||||
|
method: "GET",
|
||||||
|
path: "about",
|
||||||
|
summary: "Instance information and data-source attributions.",
|
||||||
|
auth: "none",
|
||||||
|
handler: async ({ deps }) => ({
|
||||||
|
name: "STTracker",
|
||||||
|
attributions: deps.registry
|
||||||
|
.list()
|
||||||
|
.filter((m) => m.attribution)
|
||||||
|
.map((m) => ({ pluginId: m.id, name: m.name, attribution: m.attribution })),
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
route({
|
||||||
|
method: "POST",
|
||||||
|
path: "admin/plugins/reload",
|
||||||
|
summary: "Rescan the plugin directories (admin).",
|
||||||
|
auth: "session",
|
||||||
|
admin: true,
|
||||||
|
handler: async ({ deps }) => {
|
||||||
|
await deps.registry.reload();
|
||||||
|
return deps.registry.list();
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
];
|
||||||
|
}
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
import { MEDIA_TYPES, STATUSES, VISIBILITIES } from "@sttracker/core/entries";
|
||||||
|
import { z } from "zod";
|
||||||
|
|
||||||
|
/** Shared request schemas. Core functions re-validate; these give early, documented 400s. */
|
||||||
|
export const rating = z.number().int().min(0).max(100).nullable();
|
||||||
|
export const mediaType = z.enum(MEDIA_TYPES);
|
||||||
|
export const status = z.enum(STATUSES as [string, ...string[]]);
|
||||||
|
export const visibility = z.enum(VISIBILITIES as [string, ...string[]]);
|
||||||
|
export const partialDate = z.object({
|
||||||
|
value: z.string().max(40),
|
||||||
|
precision: z.enum(["day", "month", "year", "unknown"]),
|
||||||
|
});
|
||||||
|
export const customFields = z.record(z.string(), z.unknown());
|
||||||
|
export const text = (max = 100_000) => z.string().max(max);
|
||||||
|
|
||||||
|
/** Query-string boolean: "true" / "false". */
|
||||||
|
export const qBool = z.enum(["true", "false"]).transform((v) => v === "true");
|
||||||
|
/** Query-string positive integer. */
|
||||||
|
export const qInt = z.coerce.number().int().min(1).max(1000);
|
||||||
@@ -0,0 +1,143 @@
|
|||||||
|
import {
|
||||||
|
createConnection,
|
||||||
|
deleteConnection,
|
||||||
|
devicePoll,
|
||||||
|
deviceStart,
|
||||||
|
getConnection,
|
||||||
|
isDeletedConnection,
|
||||||
|
listConnections,
|
||||||
|
listRuns,
|
||||||
|
updateConnection,
|
||||||
|
} from "@sttracker/core/connections";
|
||||||
|
import { ValidationError } from "@sttracker/core/errors";
|
||||||
|
import { enqueue } from "@sttracker/core/jobs";
|
||||||
|
import type { PluginRegistry } from "@sttracker/core/registry";
|
||||||
|
import { z } from "zod";
|
||||||
|
import { HttpError, json, noContent, route } from "../http.ts";
|
||||||
|
import { formFile } from "./form.ts";
|
||||||
|
import { qInt } from "./schemas.ts";
|
||||||
|
|
||||||
|
const ALREADY_RUNNING = "A sync for this source is already queued or running.";
|
||||||
|
|
||||||
|
function pluginOf(registry: PluginRegistry, pluginId: string) {
|
||||||
|
const plugin = registry.get(pluginId);
|
||||||
|
if (!plugin) throw new ValidationError("pluginId", "not_installed");
|
||||||
|
return plugin;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function enqueueSync(db: Parameters<typeof enqueue>[0], connectionId: string, payload: Record<string, unknown>): Promise<Response> {
|
||||||
|
const jobId = await enqueue(db, "sync", { connectionId, ...payload }, { lockKey: `sync:${connectionId}` });
|
||||||
|
if (jobId === null) throw new HttpError(409, "conflict", ALREADY_RUNNING);
|
||||||
|
return json({ jobId }, 202);
|
||||||
|
}
|
||||||
|
|
||||||
|
const schedule = z.number().int().min(1).nullable();
|
||||||
|
const config = z.record(z.string(), z.unknown());
|
||||||
|
const creds = z.record(z.string(), z.string());
|
||||||
|
|
||||||
|
export const sourceRoutes = [
|
||||||
|
route({
|
||||||
|
method: "GET",
|
||||||
|
path: "plugins",
|
||||||
|
summary: "Installed source plugins (manifests).",
|
||||||
|
auth: "any:read",
|
||||||
|
handler: async ({ deps }) => deps.registry.list(),
|
||||||
|
}),
|
||||||
|
route({
|
||||||
|
method: "GET",
|
||||||
|
path: "sources",
|
||||||
|
summary: "Your source connections (never includes secrets).",
|
||||||
|
auth: "any:read",
|
||||||
|
handler: async ({ deps, user }) => (await listConnections(deps.db, user.id)).filter((c) => !isDeletedConnection(c)),
|
||||||
|
}),
|
||||||
|
route({
|
||||||
|
method: "POST",
|
||||||
|
path: "sources",
|
||||||
|
summary: "Connect a source. Config fields typed secret are stored encrypted.",
|
||||||
|
auth: "session",
|
||||||
|
body: z
|
||||||
|
.object({ pluginId: z.string().min(1), name: z.string().min(1).max(200), config: config.optional(), credentials: creds.optional(), scheduleMinutes: schedule.optional() })
|
||||||
|
.strict(),
|
||||||
|
handler: async ({ deps, user, body }) => {
|
||||||
|
const plugin = pluginOf(deps.registry, body.pluginId);
|
||||||
|
const id = await createConnection(deps.db, deps.secret, user.id, { ...body, config: body.config ?? {} }, plugin.manifest);
|
||||||
|
return json(await getConnection(deps.db, user.id, id), 201);
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
route({
|
||||||
|
method: "PATCH",
|
||||||
|
path: "sources/:id",
|
||||||
|
summary: "Edit a source connection. Omitted secrets are kept.",
|
||||||
|
auth: "session",
|
||||||
|
body: z
|
||||||
|
.object({ name: z.string().min(1).max(200), config, credentials: creds, scheduleMinutes: schedule, status: z.enum(["active", "paused"]) })
|
||||||
|
.partial()
|
||||||
|
.strict(),
|
||||||
|
handler: async ({ deps, user, params, body }) => {
|
||||||
|
const conn = await getConnection(deps.db, user.id, params.id!);
|
||||||
|
await updateConnection(deps.db, deps.secret, user.id, conn.id, body, pluginOf(deps.registry, conn.pluginId).manifest);
|
||||||
|
return getConnection(deps.db, user.id, conn.id);
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
route({
|
||||||
|
method: "DELETE",
|
||||||
|
path: "sources/:id",
|
||||||
|
summary: "Disconnect a source: pauses it and wipes its credentials. Imported data stays.",
|
||||||
|
auth: "session",
|
||||||
|
handler: async ({ deps, user, params }) => {
|
||||||
|
await getConnection(deps.db, user.id, params.id!);
|
||||||
|
await deleteConnection(deps.db, user.id, params.id!);
|
||||||
|
return noContent();
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
route({
|
||||||
|
method: "POST",
|
||||||
|
path: "sources/:id/run",
|
||||||
|
summary: "Queue a sync now. 409 if one is already queued or running.",
|
||||||
|
auth: "any:write",
|
||||||
|
body: z.object({ mode: z.enum(["full", "changes"]).optional() }).strict().optional(),
|
||||||
|
handler: async ({ deps, user, params, body }) => {
|
||||||
|
const conn = await getConnection(deps.db, user.id, params.id!);
|
||||||
|
return enqueueSync(deps.db, conn.id, body?.mode ? { mode: body.mode } : {});
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
route({
|
||||||
|
method: "POST",
|
||||||
|
path: "sources/:id/import",
|
||||||
|
summary: "Upload an export file (multipart: file) and queue a file-mode sync.",
|
||||||
|
auth: "any:write",
|
||||||
|
multipart: true,
|
||||||
|
handler: async ({ deps, user, params, form }) => {
|
||||||
|
const conn = await getConnection(deps.db, user.id, params.id!);
|
||||||
|
if (!pluginOf(deps.registry, conn.pluginId).importFile) throw new ValidationError("pluginId", "cannot_import_files");
|
||||||
|
const file = formFile(form);
|
||||||
|
const base64 = Buffer.from(await file.arrayBuffer()).toString("base64");
|
||||||
|
return enqueueSync(deps.db, conn.id, { file: { name: file.name, base64 } });
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
route({
|
||||||
|
method: "POST",
|
||||||
|
path: "sources/:id/device/start",
|
||||||
|
summary: "Start a device-code sign-in with the source.",
|
||||||
|
auth: "session",
|
||||||
|
handler: async ({ deps, user, params }) => deviceStart(deps.db, deps.secret, deps.registry, user.id, params.id!),
|
||||||
|
}),
|
||||||
|
route({
|
||||||
|
method: "POST",
|
||||||
|
path: "sources/:id/device/poll",
|
||||||
|
summary: "Poll a device-code sign-in: pending or connected.",
|
||||||
|
auth: "session",
|
||||||
|
body: z.object({ deviceCode: z.string().min(1).max(2000) }).strict(),
|
||||||
|
handler: async ({ deps, user, params, body }) => ({
|
||||||
|
status: await devicePoll(deps.db, deps.secret, deps.registry, user.id, params.id!, body.deviceCode),
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
route({
|
||||||
|
method: "GET",
|
||||||
|
path: "sources/:id/runs",
|
||||||
|
summary: "Recent sync runs of a source, newest first.",
|
||||||
|
auth: "any:read",
|
||||||
|
query: z.object({ limit: qInt.optional() }),
|
||||||
|
handler: async ({ deps, user, params, query }) => listRuns(deps.db, user.id, params.id!, query.limit),
|
||||||
|
}),
|
||||||
|
];
|
||||||
@@ -0,0 +1,56 @@
|
|||||||
|
import { acceptSuggestion, listSuggestions, rejectSuggestion, resolveVisibleWork } from "@sttracker/core/entries";
|
||||||
|
import { NotFoundError } from "@sttracker/core/errors";
|
||||||
|
import { getWork, searchWorks } from "@sttracker/core/works";
|
||||||
|
import { z } from "zod";
|
||||||
|
import { noContent, route } from "../http.ts";
|
||||||
|
import { mediaType, qInt } from "./schemas.ts";
|
||||||
|
|
||||||
|
export const workRoutes = [
|
||||||
|
route({
|
||||||
|
method: "GET",
|
||||||
|
path: "works/search",
|
||||||
|
summary: "Search works visible to you by title.",
|
||||||
|
auth: "any:read",
|
||||||
|
query: z.object({ q: z.string().min(1).max(500), mediaType: mediaType.optional(), limit: qInt.optional() }),
|
||||||
|
handler: async ({ deps, user, query }) => searchWorks(deps.db, user.id, query.q, { mediaType: query.mediaType, limit: query.limit }),
|
||||||
|
}),
|
||||||
|
route({
|
||||||
|
method: "GET",
|
||||||
|
path: "works/:id",
|
||||||
|
summary: "One work (follows merges and your aliases).",
|
||||||
|
auth: "any:read",
|
||||||
|
handler: async ({ deps, user, params }) => {
|
||||||
|
const visible = await resolveVisibleWork(deps.db.k, user.id, params.id!);
|
||||||
|
const work = await getWork(deps.db, visible.id);
|
||||||
|
if (!work) throw new NotFoundError("work");
|
||||||
|
return work;
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
route({
|
||||||
|
method: "GET",
|
||||||
|
path: "works/:id/suggestions",
|
||||||
|
summary: "Pending match suggestions for a work.",
|
||||||
|
auth: "any:read",
|
||||||
|
handler: async ({ deps, user, params }) => listSuggestions(deps.db, user.id, params.id!),
|
||||||
|
}),
|
||||||
|
route({
|
||||||
|
method: "POST",
|
||||||
|
path: "suggestions/:id/accept",
|
||||||
|
summary: "Accept a match suggestion (moves your entry to the candidate work).",
|
||||||
|
auth: "any:write",
|
||||||
|
handler: async ({ deps, user, params }) => {
|
||||||
|
await acceptSuggestion(deps.db, user.id, params.id!);
|
||||||
|
return noContent();
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
route({
|
||||||
|
method: "POST",
|
||||||
|
path: "suggestions/:id/reject",
|
||||||
|
summary: "Reject a match suggestion.",
|
||||||
|
auth: "any:write",
|
||||||
|
handler: async ({ deps, user, params }) => {
|
||||||
|
await rejectSuggestion(deps.db, user.id, params.id!);
|
||||||
|
return noContent();
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
];
|
||||||
@@ -0,0 +1,309 @@
|
|||||||
|
import { afterEach, beforeEach, describe, expect, spyOn, test } from "bun:test";
|
||||||
|
import { mkdtemp, rm } from "node:fs/promises";
|
||||||
|
import { tmpdir } from "node:os";
|
||||||
|
import { join } from "node:path";
|
||||||
|
import { loadPlugins, type PluginRegistry } from "@sttracker/core/registry";
|
||||||
|
import { forEachDialect } from "@sttracker/db/testing";
|
||||||
|
import { createApp } from "../src/app.ts";
|
||||||
|
import { loadConfig } from "../src/config.ts";
|
||||||
|
import { sessionCookie as buildSessionCookie } from "../src/http.ts";
|
||||||
|
import { safeFilename } from "../src/routes/attachments.ts";
|
||||||
|
|
||||||
|
const ECHO_DIR = join(import.meta.dir, "../../../packages/core/test/fixtures/plugins");
|
||||||
|
// The fixture dir also holds a deliberately broken plugin; silence its load warning.
|
||||||
|
const warn = spyOn(console, "warn").mockImplementation(() => {});
|
||||||
|
const registry: PluginRegistry = await loadPlugins([ECHO_DIR]);
|
||||||
|
warn.mockRestore();
|
||||||
|
const BASE = "http://localhost/api/v1";
|
||||||
|
|
||||||
|
type App = ReturnType<typeof createApp>;
|
||||||
|
interface CallOpts {
|
||||||
|
body?: unknown;
|
||||||
|
cookie?: string;
|
||||||
|
token?: string;
|
||||||
|
headers?: Record<string, string>;
|
||||||
|
form?: FormData;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function call(app: App, method: string, path: string, o: CallOpts = {}): Promise<Response> {
|
||||||
|
const headers: Record<string, string> = { ...o.headers };
|
||||||
|
if (o.cookie) {
|
||||||
|
headers.cookie = o.cookie;
|
||||||
|
headers["x-requested-with"] ??= "sttracker";
|
||||||
|
}
|
||||||
|
if (o.token) headers.authorization = `Bearer ${o.token}`;
|
||||||
|
let body: BodyInit | undefined;
|
||||||
|
if (o.form) body = o.form;
|
||||||
|
else if (o.body !== undefined) {
|
||||||
|
headers["content-type"] = "application/json";
|
||||||
|
body = JSON.stringify(o.body);
|
||||||
|
}
|
||||||
|
const url = path.startsWith("http") ? path : `${BASE}/${path}`;
|
||||||
|
return app.fetch(new Request(url, { method, headers, body }));
|
||||||
|
}
|
||||||
|
|
||||||
|
function sessionCookie(res: Response): string {
|
||||||
|
const set = res.headers.get("set-cookie") ?? "";
|
||||||
|
const m = /stt_session=([^;]*)/.exec(set);
|
||||||
|
if (!m) throw new Error(`no session cookie in ${set}`);
|
||||||
|
return `stt_session=${m[1]}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function signUp(app: App, username: string, inviteCode?: string): Promise<string> {
|
||||||
|
const reg = await call(app, "POST", "auth/register", { body: { username, password: "correct horse", inviteCode } });
|
||||||
|
expect(reg.status).toBe(201);
|
||||||
|
const res = await call(app, "POST", "auth/login", { body: { username, password: "correct horse" } });
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
return sessionCookie(res);
|
||||||
|
}
|
||||||
|
|
||||||
|
test("booting without STTRACKER_SECRET throws the exact message", () => {
|
||||||
|
expect(() => loadConfig({})).toThrow("Set STTRACKER_SECRET to a long random string. It encrypts source credentials.");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("safeFilename strips quotes, newlines and path characters", () => {
|
||||||
|
expect(safeFilename('a"b\r\nc/../d.png')).toBe("a_b__c_.._d.png");
|
||||||
|
expect(safeFilename("")).toBe("file");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("session cookie is Secure only behind an https PUBLIC_URL", () => {
|
||||||
|
expect(buildSessionCookie("t", "https://st.example")).toContain("; Secure");
|
||||||
|
expect(buildSessionCookie("t", "http://localhost:3000")).not.toContain("Secure");
|
||||||
|
expect(buildSessionCookie("t", undefined)).toContain("HttpOnly; SameSite=Lax");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("config defaults", () => {
|
||||||
|
const c = loadConfig({ STTRACKER_SECRET: "s" });
|
||||||
|
expect(c).toMatchObject({
|
||||||
|
dataDir: "./data",
|
||||||
|
databaseUrl: "sqlite://./data/sttracker.db",
|
||||||
|
port: 3000,
|
||||||
|
pluginDirs: ["plugins", "./data/plugins"],
|
||||||
|
});
|
||||||
|
const d = loadConfig({ STTRACKER_SECRET: "s", DATA_DIR: "/srv/st", PORT: "4000", PLUGIN_DIRS: "a, b" });
|
||||||
|
expect(d).toMatchObject({ databaseUrl: "sqlite:///srv/st/sttracker.db", port: 4000, pluginDirs: ["a", "b"] });
|
||||||
|
});
|
||||||
|
|
||||||
|
forEachDialect("api", (getDb) => {
|
||||||
|
let dataDir: string;
|
||||||
|
let app: App;
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
dataDir = await mkdtemp(join(tmpdir(), "stt-api-"));
|
||||||
|
app = createApp({ db: getDb(), registry, secret: "test-secret", dataDir });
|
||||||
|
});
|
||||||
|
afterEach(async () => {
|
||||||
|
await rm(dataDir, { recursive: true, force: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
test("register, login cookie, GET me", async () => {
|
||||||
|
const reg = await call(app, "POST", "auth/register", { body: { username: "ana", password: "correct horse" } });
|
||||||
|
expect(reg.status).toBe(201);
|
||||||
|
const login = await call(app, "POST", "auth/login", { body: { username: "ana", password: "correct horse" } });
|
||||||
|
expect(login.status).toBe(200);
|
||||||
|
const set = login.headers.get("set-cookie")!;
|
||||||
|
expect(set).toContain("HttpOnly");
|
||||||
|
expect(set).toContain("SameSite=Lax");
|
||||||
|
expect(set).not.toContain("Secure");
|
||||||
|
const me = await call(app, "GET", "me", { cookie: sessionCookie(login) });
|
||||||
|
expect(me.status).toBe(200);
|
||||||
|
const body = (await me.json()) as Record<string, unknown>;
|
||||||
|
expect(body).toMatchObject({ username: "ana", role: "admin" });
|
||||||
|
expect(JSON.stringify(body)).not.toContain("password");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("bad login is 401 JSON", async () => {
|
||||||
|
await call(app, "POST", "auth/register", { body: { username: "ana", password: "correct horse" } });
|
||||||
|
const res = await call(app, "POST", "auth/login", { body: { username: "ana", password: "nope nope" } });
|
||||||
|
expect(res.status).toBe(401);
|
||||||
|
expect(((await res.json()) as { error: { code: string } }).error.code).toBe("unauthenticated");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("unauthenticated GET entries is 401 JSON", async () => {
|
||||||
|
const res = await call(app, "GET", "entries");
|
||||||
|
expect(res.status).toBe(401);
|
||||||
|
expect(res.headers.get("content-type")).toContain("application/json");
|
||||||
|
const body = (await res.json()) as { error: { code: string; message: string } };
|
||||||
|
expect(body.error.code).toBe("unauthenticated");
|
||||||
|
expect(typeof body.error.message).toBe("string");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("quick add, then the list shows a draft", async () => {
|
||||||
|
const cookie = await signUp(app, "ana");
|
||||||
|
const add = await call(app, "POST", "entries/quick", { cookie, body: { title: "Dune", mediaType: "book" } });
|
||||||
|
expect(add.status).toBe(201);
|
||||||
|
const list = await call(app, "GET", "entries?draft=true", { cookie });
|
||||||
|
expect(list.status).toBe(200);
|
||||||
|
const body = (await list.json()) as { items: { draft: boolean; work: { title: string } }[] };
|
||||||
|
expect(body.items).toHaveLength(1);
|
||||||
|
expect(body.items[0]).toMatchObject({ draft: true, work: { title: "Dune" } });
|
||||||
|
});
|
||||||
|
|
||||||
|
test("PATCH rating 150 is 400 validation; 80 works", async () => {
|
||||||
|
const cookie = await signUp(app, "ana");
|
||||||
|
const add = (await (await call(app, "POST", "entries/quick", { cookie, body: { title: "Dune" } })).json()) as { id: string };
|
||||||
|
const bad = await call(app, "PATCH", `entries/${add.id}`, { cookie, body: { rating: 150 } });
|
||||||
|
expect(bad.status).toBe(400);
|
||||||
|
const err = (await bad.json()) as { error: { code: string; message: string } };
|
||||||
|
expect(err.error.code).toBe("validation");
|
||||||
|
expect(err.error.message).toContain("rating");
|
||||||
|
const ok = await call(app, "PATCH", `entries/${add.id}`, { cookie, body: { rating: 80 } });
|
||||||
|
expect(ok.status).toBe(200);
|
||||||
|
expect(((await ok.json()) as { rating: number }).rating).toBe(80);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("an app token with read can GET but cannot PATCH (403)", async () => {
|
||||||
|
const cookie = await signUp(app, "ana");
|
||||||
|
const add = (await (await call(app, "POST", "entries/quick", { cookie, body: { title: "Dune" } })).json()) as { id: string };
|
||||||
|
const mk = await call(app, "POST", "me/tokens", { cookie, body: { name: "script", scopes: ["read"] } });
|
||||||
|
expect(mk.status).toBe(201);
|
||||||
|
const { token } = (await mk.json()) as { token: string };
|
||||||
|
expect((await call(app, "GET", `entries/${add.id}`, { token })).status).toBe(200);
|
||||||
|
const res = await call(app, "PATCH", `entries/${add.id}`, { token, body: { rating: 10 } });
|
||||||
|
expect(res.status).toBe(403);
|
||||||
|
expect(((await res.json()) as { error: { code: string } }).error.code).toBe("forbidden");
|
||||||
|
const list = (await (await call(app, "GET", "me/tokens", { cookie })).json()) as Record<string, unknown>[];
|
||||||
|
expect(list).toHaveLength(1);
|
||||||
|
expect(JSON.stringify(list)).not.toContain("hash");
|
||||||
|
expect((await call(app, "GET", "entries", { token: "stt_bogus" })).status).toBe(401);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("another user's entry is 404", async () => {
|
||||||
|
const ana = await signUp(app, "ana");
|
||||||
|
const inv = await call(app, "POST", "admin/invites", { cookie: ana });
|
||||||
|
expect(inv.status).toBe(201);
|
||||||
|
const { code } = (await inv.json()) as { code: string };
|
||||||
|
const bob = await signUp(app, "bob", code);
|
||||||
|
const add = (await (await call(app, "POST", "entries/quick", { cookie: ana, body: { title: "Dune" } })).json()) as { id: string };
|
||||||
|
expect((await call(app, "GET", `entries/${add.id}`, { cookie: bob })).status).toBe(404);
|
||||||
|
const patch = await call(app, "PATCH", `entries/${add.id}`, { cookie: bob, body: { rating: 10 } });
|
||||||
|
expect(patch.status).toBe(404);
|
||||||
|
expect(((await patch.json()) as { error: { code: string } }).error.code).toBe("not_found");
|
||||||
|
expect((await call(app, "POST", "admin/invites", { cookie: bob })).status).toBe(403);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("cookie-authenticated writes need the CSRF header", async () => {
|
||||||
|
const cookie = await signUp(app, "ana");
|
||||||
|
const res = await call(app, "POST", "entries/quick", { cookie, body: { title: "Dune" }, headers: { "x-requested-with": "" } });
|
||||||
|
expect(res.status).toBe(403);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("login is rate limited to 10 per minute", async () => {
|
||||||
|
let last = 0;
|
||||||
|
for (let i = 0; i < 11; i++) {
|
||||||
|
last = (await call(app, "POST", "auth/login", { body: { username: "x", password: "y" } })).status;
|
||||||
|
}
|
||||||
|
expect(last).toBe(429);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("JSON bodies over 1 MB are rejected", async () => {
|
||||||
|
const cookie = await signUp(app, "ana");
|
||||||
|
const res = await call(app, "POST", "entries/quick", { cookie, body: { title: "x".repeat(1_100_000) } });
|
||||||
|
expect(res.status).toBe(413);
|
||||||
|
expect(((await res.json()) as { error: { code: string } }).error.code).toBe("validation");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("sources: create with echo, run twice gives 409, secrets never returned", async () => {
|
||||||
|
const cookie = await signUp(app, "ana");
|
||||||
|
const plugins = (await (await call(app, "GET", "plugins", { cookie })).json()) as { id: string }[];
|
||||||
|
expect(plugins.map((p) => p.id)).toContain("echo");
|
||||||
|
const mk = await call(app, "POST", "sources", { cookie, body: { pluginId: "echo", name: "E", config: { label: "x", token: "sekrit" } } });
|
||||||
|
expect(mk.status).toBe(201);
|
||||||
|
const { id } = (await mk.json()) as { id: string };
|
||||||
|
const listed = await (await call(app, "GET", "sources", { cookie })).text();
|
||||||
|
expect(listed).not.toContain("sekrit");
|
||||||
|
expect(listed).toContain('"hasCredentials":true');
|
||||||
|
expect((await call(app, "POST", `sources/${id}/run`, { cookie })).status).toBe(202);
|
||||||
|
const again = await call(app, "POST", `sources/${id}/run`, { cookie });
|
||||||
|
expect(again.status).toBe(409);
|
||||||
|
expect(await again.json()).toEqual({
|
||||||
|
error: { code: "conflict", message: "A sync for this source is already queued or running." },
|
||||||
|
});
|
||||||
|
expect((await call(app, "GET", `sources/${id}/runs`, { cookie })).status).toBe(200);
|
||||||
|
expect((await call(app, "DELETE", `sources/${id}`, { cookie })).status).toBe(204);
|
||||||
|
expect((await (await call(app, "GET", "sources", { cookie })).json()) as unknown[]).toHaveLength(0);
|
||||||
|
expect((await call(app, "POST", `sources/${id}/run`, { cookie })).status).toBe(404);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("sources: unknown plugin is 400; file import without importFile is 400", async () => {
|
||||||
|
const cookie = await signUp(app, "ana");
|
||||||
|
const bad = await call(app, "POST", "sources", { cookie, body: { pluginId: "nope", name: "E" } });
|
||||||
|
expect(bad.status).toBe(400);
|
||||||
|
const { id } = (await (await call(app, "POST", "sources", { cookie, body: { pluginId: "echo", name: "E" } })).json()) as { id: string };
|
||||||
|
const form = new FormData();
|
||||||
|
form.set("file", new File(["a,b"], "x.csv", { type: "text/csv" }));
|
||||||
|
expect((await call(app, "POST", `sources/${id}/import`, { cookie, form })).status).toBe(400);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("export bundle is a zip; csv picks a file", async () => {
|
||||||
|
const cookie = await signUp(app, "ana");
|
||||||
|
const res = await call(app, "GET", "export/bundle", { cookie });
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
expect(res.headers.get("content-type")).toBe("application/zip");
|
||||||
|
const bytes = new Uint8Array(await res.arrayBuffer());
|
||||||
|
expect([bytes[0], bytes[1]]).toEqual([0x50, 0x4b]);
|
||||||
|
const csv = await call(app, "GET", "export/csv?file=entries.csv", { cookie });
|
||||||
|
expect(csv.status).toBe(200);
|
||||||
|
expect(csv.headers.get("content-type")).toContain("text/csv");
|
||||||
|
expect((await call(app, "GET", "export/csv?file=nope.csv", { cookie })).status).toBe(404);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("attachments: upload to own entry, serve with safe headers; another user's entry is 404", async () => {
|
||||||
|
const ana = await signUp(app, "ana");
|
||||||
|
const add = (await (await call(app, "POST", "entries/quick", { cookie: ana, body: { title: "Dune" } })).json()) as { id: string };
|
||||||
|
const form = new FormData();
|
||||||
|
form.set("file", new File([new Uint8Array([0x89, 0x50, 0x4e, 0x47])], 'we<ird>na;me?.png', { type: "image/png" }));
|
||||||
|
form.set("entity", "entry");
|
||||||
|
form.set("entityId", add.id);
|
||||||
|
const up = await call(app, "POST", "attachments", { cookie: ana, form });
|
||||||
|
expect(up.status).toBe(201);
|
||||||
|
const att = (await up.json()) as { id: string; links: { id: string }[] };
|
||||||
|
const got = await call(app, "GET", `attachments/${att.id}`, { cookie: ana });
|
||||||
|
expect(got.status).toBe(200);
|
||||||
|
expect(got.headers.get("content-type")).toBe("image/png");
|
||||||
|
expect(got.headers.get("x-content-type-options")).toBe("nosniff");
|
||||||
|
expect(got.headers.get("content-disposition")).toBe('inline; filename="we_ird_na_me_.png"');
|
||||||
|
|
||||||
|
const form2 = new FormData();
|
||||||
|
form2.set("file", new File([new Uint8Array([1])], "a.png", { type: "image/png" }));
|
||||||
|
form2.set("entity", "entry");
|
||||||
|
form2.set("entityId", "0190f000-0000-7000-8000-000000000000");
|
||||||
|
expect((await call(app, "POST", "attachments", { cookie: ana, form: form2 })).status).toBe(404);
|
||||||
|
|
||||||
|
expect((await call(app, "DELETE", `attachments/${att.id}/links/${att.links[0]!.id}`, { cookie: ana })).status).toBe(204);
|
||||||
|
expect((await call(app, "GET", `attachments/${att.id}`, { cookie: ana })).status).toBe(404);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("openapi.json lists /api/v1/entries", async () => {
|
||||||
|
const res = await call(app, "GET", "openapi.json");
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
const doc = (await res.json()) as { openapi: string; paths: Record<string, Record<string, { summary: string }>> };
|
||||||
|
expect(doc.openapi).toStartWith("3.");
|
||||||
|
expect(doc.paths["/api/v1/entries"]?.get?.summary).toBeTruthy();
|
||||||
|
expect(doc.paths["/api/v1/entries/{id}"]?.patch).toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("about lists attributions; ingest and unknown routes are 404 JSON", async () => {
|
||||||
|
expect((await call(app, "GET", "about")).status).toBe(200);
|
||||||
|
const ing = await call(app, "POST", "http://localhost/ingest/echo/hook");
|
||||||
|
expect(ing.status).toBe(404);
|
||||||
|
expect(((await ing.json()) as { error: { code: string } }).error.code).toBe("not_found");
|
||||||
|
expect((await call(app, "GET", "nope")).status).toBe(404);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("internal errors", () => {
|
||||||
|
test("are 500 with a generic message", async () => {
|
||||||
|
const broken = { k: null, dialect: "sqlite", close: async () => {} } as never;
|
||||||
|
const app = createApp({ db: broken, registry, secret: "s", dataDir: "/nonexistent" });
|
||||||
|
const orig = console.error;
|
||||||
|
console.error = () => {};
|
||||||
|
try {
|
||||||
|
const res = await call(app, "POST", "auth/login", { body: { username: "a", password: "b" } });
|
||||||
|
expect(res.status).toBe(500);
|
||||||
|
expect(await res.json()).toEqual({ error: { code: "internal", message: "Something went wrong on the server." } });
|
||||||
|
} finally {
|
||||||
|
console.error = orig;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
import { expect, test } from "bun:test";
|
||||||
|
import { forEachDialect } from "@sttracker/db/testing";
|
||||||
|
import { ensureBackupScheduled, nextBackupAt } from "../src/boot.ts";
|
||||||
|
|
||||||
|
test("nextBackupAt is the next 03:00 local time", () => {
|
||||||
|
const before = new Date(2026, 9, 30, 2, 59);
|
||||||
|
expect(nextBackupAt(before).getTime()).toBe(new Date(2026, 9, 30, 3, 0).getTime());
|
||||||
|
const after = new Date(2026, 9, 30, 3, 0);
|
||||||
|
expect(nextBackupAt(after).getTime()).toBe(new Date(2026, 9, 31, 3, 0).getTime());
|
||||||
|
});
|
||||||
|
|
||||||
|
forEachDialect("boot", (getDb) => {
|
||||||
|
test("ensureBackupScheduled queues one backup job under lockKey backup", async () => {
|
||||||
|
const db = getDb();
|
||||||
|
expect(await ensureBackupScheduled(db)).not.toBeNull();
|
||||||
|
expect(await ensureBackupScheduled(db)).toBeNull();
|
||||||
|
const jobs = await db.k.selectFrom("jobs").selectAll().where("type", "=", "backup").execute();
|
||||||
|
expect(jobs).toHaveLength(1);
|
||||||
|
expect(jobs[0]).toMatchObject({ lock_key: "backup", status: "queued" });
|
||||||
|
expect(new Date(jobs[0]!.run_at).getHours()).toBe(3);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
<!doctype html><title>STTracker</title><div id="root">STTracker is starting…</div>
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
{
|
||||||
|
"name": "@sttracker/web",
|
||||||
|
"private": true,
|
||||||
|
"type": "module",
|
||||||
|
"exports": {
|
||||||
|
"./*": "./src/*.ts"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@tanstack/react-query": "^5.105.0",
|
||||||
|
"react": "^19.3.1",
|
||||||
|
"react-dom": "^19.3.1",
|
||||||
|
"wouter": "^3.13.0"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@types/react": "^19.3.0",
|
||||||
|
"@types/react-dom": "^19.3.0"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -9,18 +9,150 @@
|
|||||||
"typescript": "^5",
|
"typescript": "^5",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
"apps/server": {
|
||||||
|
"name": "@sttracker/server",
|
||||||
|
"dependencies": {
|
||||||
|
"@sttracker/core": "workspace:*",
|
||||||
|
"@sttracker/db": "workspace:*",
|
||||||
|
"@sttracker/plugin-sdk": "workspace:*",
|
||||||
|
"kysely": "^0.29.7",
|
||||||
|
"zod": "^4.6.7",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"apps/web": {
|
||||||
|
"name": "@sttracker/web",
|
||||||
|
"dependencies": {
|
||||||
|
"@tanstack/react-query": "^5.105.0",
|
||||||
|
"react": "^19.3.1",
|
||||||
|
"react-dom": "^19.3.1",
|
||||||
|
"wouter": "^3.13.0",
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@types/react": "^19.3.0",
|
||||||
|
"@types/react-dom": "^19.3.0",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"packages/core": {
|
||||||
|
"name": "@sttracker/core",
|
||||||
|
"dependencies": {
|
||||||
|
"@sttracker/db": "workspace:*",
|
||||||
|
"@sttracker/plugin-sdk": "workspace:*",
|
||||||
|
"fflate": "^0.8.3",
|
||||||
|
"kysely": "^0.29.7",
|
||||||
|
"zod": "^4.6.7",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"packages/db": {
|
||||||
|
"name": "@sttracker/db",
|
||||||
|
"dependencies": {
|
||||||
|
"kysely": "^0.29.7",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"packages/plugin-sdk": {
|
||||||
|
"name": "@sttracker/plugin-sdk",
|
||||||
|
"dependencies": {
|
||||||
|
"fflate": "^0.8.3",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"plugins/csv": {
|
||||||
|
"name": "@sttracker/plugin-csv",
|
||||||
|
"dependencies": {
|
||||||
|
"@sttracker/plugin-sdk": "workspace:*",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"plugins/hardcover": {
|
||||||
|
"name": "@sttracker/plugin-hardcover",
|
||||||
|
"dependencies": {
|
||||||
|
"@sttracker/plugin-sdk": "workspace:*",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"plugins/lastfm": {
|
||||||
|
"name": "@sttracker/plugin-lastfm",
|
||||||
|
"dependencies": {
|
||||||
|
"@sttracker/plugin-sdk": "workspace:*",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"plugins/listenbrainz": {
|
||||||
|
"name": "@sttracker/plugin-listenbrainz",
|
||||||
|
"dependencies": {
|
||||||
|
"@sttracker/plugin-sdk": "workspace:*",
|
||||||
|
"fflate": "^0.8.3",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"plugins/mangabaka": {
|
||||||
|
"name": "@sttracker/plugin-mangabaka",
|
||||||
|
"dependencies": {
|
||||||
|
"@sttracker/plugin-sdk": "workspace:*",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"plugins/trakt": {
|
||||||
|
"name": "@sttracker/plugin-trakt",
|
||||||
|
"dependencies": {
|
||||||
|
"@sttracker/plugin-sdk": "workspace:*",
|
||||||
|
},
|
||||||
|
},
|
||||||
},
|
},
|
||||||
"packages": {
|
"packages": {
|
||||||
|
"@sttracker/core": ["@sttracker/core@workspace:packages/core"],
|
||||||
|
|
||||||
|
"@sttracker/db": ["@sttracker/db@workspace:packages/db"],
|
||||||
|
|
||||||
|
"@sttracker/plugin-csv": ["@sttracker/plugin-csv@workspace:plugins/csv"],
|
||||||
|
|
||||||
|
"@sttracker/plugin-hardcover": ["@sttracker/plugin-hardcover@workspace:plugins/hardcover"],
|
||||||
|
|
||||||
|
"@sttracker/plugin-lastfm": ["@sttracker/plugin-lastfm@workspace:plugins/lastfm"],
|
||||||
|
|
||||||
|
"@sttracker/plugin-listenbrainz": ["@sttracker/plugin-listenbrainz@workspace:plugins/listenbrainz"],
|
||||||
|
|
||||||
|
"@sttracker/plugin-mangabaka": ["@sttracker/plugin-mangabaka@workspace:plugins/mangabaka"],
|
||||||
|
|
||||||
|
"@sttracker/plugin-sdk": ["@sttracker/plugin-sdk@workspace:packages/plugin-sdk"],
|
||||||
|
|
||||||
|
"@sttracker/plugin-trakt": ["@sttracker/plugin-trakt@workspace:plugins/trakt"],
|
||||||
|
|
||||||
|
"@sttracker/server": ["@sttracker/server@workspace:apps/server"],
|
||||||
|
|
||||||
|
"@sttracker/web": ["@sttracker/web@workspace:apps/web"],
|
||||||
|
|
||||||
|
"@tanstack/query-core": ["@tanstack/[email protected]", "", {}, "sha512-7p56vRmT/z4UJ6sctGIdl5WRUQyUv53IUpUwEKi2g97zMbO7zh8ZsCLffGAvCPBR4xD72e3VXsDyLwXHhoNTMQ=="],
|
||||||
|
|
||||||
|
"@tanstack/react-query": ["@tanstack/[email protected]", "", { "dependencies": { "@tanstack/query-core": "5.105.0" }, "peerDependencies": { "@types/react": "^18 || ^19", "react": "^18 || ^19" }, "optionalPeers": ["@types/react"] }, "sha512-y+hMuDERvgg6ay1da0V1a6ZC0ZONt7dELxSFkqRZuwm7p1TTZs0MoXrPlBRflIpRoYjvzQVYxycnLImpFLhWrw=="],
|
||||||
|
|
||||||
"@types/bun": ["@types/[email protected]", "", { "dependencies": { "bun-types": "1.4.4" } }, "sha512-9wkRDXUXK124pEnAutE6n/l18j+i9GC5IAHK8TmX/2nlySETmky0OU6yL5U8pW1AoKh++AN9auDCr647BfurjQ=="],
|
"@types/bun": ["@types/[email protected]", "", { "dependencies": { "bun-types": "1.4.4" } }, "sha512-9wkRDXUXK124pEnAutE6n/l18j+i9GC5IAHK8TmX/2nlySETmky0OU6yL5U8pW1AoKh++AN9auDCr647BfurjQ=="],
|
||||||
|
|
||||||
"@types/node": ["@types/[email protected]", "", { "dependencies": { "undici-types": "~8.9.0" } }, "sha512-MzQj+8Kx3XI40OmUAE6Qoh58ZMNstNNMwn2nfd3qWsfe7nDNSqJNzes4njhz6AHwrSHOffSLwXU6yb474Q4aog=="],
|
"@types/node": ["@types/[email protected]", "", { "dependencies": { "undici-types": "~8.9.0" } }, "sha512-MzQj+8Kx3XI40OmUAE6Qoh58ZMNstNNMwn2nfd3qWsfe7nDNSqJNzes4njhz6AHwrSHOffSLwXU6yb474Q4aog=="],
|
||||||
|
|
||||||
|
"@types/react": ["@types/[email protected]", "", { "dependencies": { "csstype": "^3.2.2" } }, "sha512-N0rFCuH9YoxG9/m61l9MfpJKfmLOVU0em7ipIz6TRgSSkvReLB9vL85GB+yr8Bs5leqpvg96JSwF4ZS1s4viQg=="],
|
||||||
|
|
||||||
|
"@types/react-dom": ["@types/[email protected]", "", { "peerDependencies": { "@types/react": "^19.3.0" } }, "sha512-ZI7bU42mZXXKHn/qNLEw2IrbiINU7X5+vfgdixBHkCNpYWXjKgfQ/P+uyGb5CjOLB9UcnTeg3rylQtV2hym44Q=="],
|
||||||
|
|
||||||
"bun-types": ["[email protected]", "", { "dependencies": { "@types/node": "*", "undici-types": "*" } }, "sha512-yjrJh5oGX7mMPKGd19YXyIjmEuj4G4zNC30bs7PM9Az7NQ3ndF3j6hbeRdvJTRQEhJ/FYQwW0a2moweEi2hMxQ=="],
|
"bun-types": ["[email protected]", "", { "dependencies": { "@types/node": "*", "undici-types": "*" } }, "sha512-yjrJh5oGX7mMPKGd19YXyIjmEuj4G4zNC30bs7PM9Az7NQ3ndF3j6hbeRdvJTRQEhJ/FYQwW0a2moweEi2hMxQ=="],
|
||||||
|
|
||||||
|
"csstype": ["[email protected]", "", {}, "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ=="],
|
||||||
|
|
||||||
|
"fflate": ["[email protected]", "", {}, "sha512-tbZNuJrLwGUp3zshBtdy4W+ORxZuIh8a5ilyIEQDC5rY1f3U20JMry0Ll3WBzU58EZKsEuJFXhb5gwv8CsPvgA=="],
|
||||||
|
|
||||||
|
"kysely": ["[email protected]", "", {}, "sha512-fmIq+7kv4WbFm2tcfznRzikqB4cuNpgZ40PSU2fAJ2/OO5XhojqnTPrmgrCjYSRJVwBln03XcRXJGnw9H099IQ=="],
|
||||||
|
|
||||||
|
"react": ["[email protected]", "", {}, "sha512-QNhWdt7nrgVWWQG58QuGsAR3jv3Hbe4mb4vz7w2csnHavciZJgda1CPNaldF6eVxv90ud3jUXpKlHL69gO38yw=="],
|
||||||
|
|
||||||
|
"react-dom": ["[email protected]", "", { "dependencies": { "scheduler": "^0.28.0" }, "peerDependencies": { "react": "^19.3.1" } }, "sha512-Y0bARz53lhEC1NMCASs0/j+faB/zqt3R8hpvg1e8+61ZVJ4UL0UdC4cBbXfZMcIuhrfgeILTLe+ZeBCavQPtKQ=="],
|
||||||
|
|
||||||
|
"regexparam": ["[email protected]", "", {}, "sha512-RSYAtP31mvYLkAHrOlh25pCNQ5hWnT106VukGaaFfuJrZFkGRX5GhUAdPqpSDXxOhA2c4akmRuplv1mRqnBn6Q=="],
|
||||||
|
|
||||||
|
"scheduler": ["[email protected]", "", {}, "sha512-juorfCmIkIw8tT+p5BXSm6PJjQF/ycEYmKyzURCIt/RaZIhL+PulbQ9Yu2z1HdOJDdqDTlxA1+xKBmHXJsczAw=="],
|
||||||
|
|
||||||
"typescript": ["[email protected]", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="],
|
"typescript": ["[email protected]", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="],
|
||||||
|
|
||||||
"undici-types": ["[email protected]", "", {}, "sha512-EXE4ydL3S76dFb27UFjNVZHTyv/TLdATj/KUe29ptdcg6qXL48J2tZ3126E5kICj5jn781OR+XlVH1N6Mz8jbA=="],
|
"undici-types": ["[email protected]", "", {}, "sha512-EXE4ydL3S76dFb27UFjNVZHTyv/TLdATj/KUe29ptdcg6qXL48J2tZ3126E5kICj5jn781OR+XlVH1N6Mz8jbA=="],
|
||||||
|
|
||||||
|
"use-sync-external-store": ["[email protected]", "", { "peerDependencies": { "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-6L+EeigHMQhdaIPNIFUKwfWJSwWFQ8gJbJ2DLOs5sDIegTwR9fRxvnM3uciHKjIZhFz+KAv2emhWMRvDmMcY8A=="],
|
||||||
|
|
||||||
|
"wouter": ["[email protected]", "", { "dependencies": { "regexparam": "^3.0.0", "use-sync-external-store": "^1.0.0" }, "peerDependencies": { "react": ">=16.8.0" } }, "sha512-p1RoCuVyxwb3X5jLjDdkwDzlXj+/UGZqZb+806uaJOVEHvNfGkjET0KiQ/CGiDKC90rLsALMgMj4onNVKxgNdQ=="],
|
||||||
|
|
||||||
|
"zod": ["[email protected]", "", {}, "sha512-g8W2D2kU6a8untc053fX1uVBKzM7yqAxW/3zwk83+6RyF2+SKOuV+c0wBDhUwzV1PaGapmZH+rmcUzPf+3CGwg=="],
|
||||||
|
|
||||||
"@types/node/undici-types": ["[email protected]", "", {}, "sha512-KTDyRTYX8sWmKXAikPHHSyc63CRPETMctyjKFupcC6OBLXT3xsN0e9aF7m+mIXutFWpUXuedtowG7iLOzp0kQg=="],
|
"@types/node/undici-types": ["[email protected]", "", {}, "sha512-KTDyRTYX8sWmKXAikPHHSyc63CRPETMctyjKFupcC6OBLXT3xsN0e9aF7m+mIXutFWpUXuedtowG7iLOzp0kQg=="],
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+34
-11
@@ -14,22 +14,45 @@ user layer or files a Changes-inbox event.
|
|||||||
## Map
|
## Map
|
||||||
| Path | Responsibility | State |
|
| Path | Responsibility | State |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
| `apps/server` | `Bun.serve`: `/api/v1`, `/ingest/<plugin>`, job worker, static web | planned |
|
| `apps/server` | `main.ts` boots (config → `createDb` → `migrateToLatest` → `loadPlugins` → worker + scheduler + daily backup → `Bun.serve`). `app.ts` `createApp(deps)` = `/api/v1` route table + `/ingest/*` mount (404 in Phase 1), testable via `app.fetch`. `http.ts` = `route()`, auth (session cookie / Bearer app token), CSRF header, body caps, rate limit, error mapping. `routes/<area>.ts` one file per area; `openapi.ts` builds `/api/v1/openapi.json` from the table | exists |
|
||||||
| `apps/web` | React app (HTML imports) | planned |
|
| `apps/web` | React app (HTML imports); `index.html` is a placeholder until Task 17 | placeholder |
|
||||||
| `packages/core` | domain services: entries, matching, change engine, history, export | planned |
|
| `packages/core` | domain services, one module per area (import by path, no barrel): `auth`, `crypto`, `works`, `matching`, `entries` (entries, library listing, quick add, per-user moves, match suggestions), `engagements`, `history` (`user_history`, restore), `lists`, `copies`, `custom-fields`, `attachments`, `export` (per-user bundle, CSV, restore), `backup` (scheduled bundles + SQLite `VACUUM INTO`), `sync/` (the change engine: `snapshot` hashing and versions, `apply` per-record diff and apply, `run` = `runSync`, `changes` = Changes inbox `listChanges`/`resolveChange`, `listens` = `ingestListens`), `jobs` (queue, worker, scheduler), `registry` (`loadPlugins`), `connections` (CRUD, plugin context, `syncHandler`) | exists (partial) |
|
||||||
| `packages/db` | Kysely, SQLite/Postgres dialects, migrations | planned |
|
| `packages/db` | Kysely over one Bun.SQL dialect (SQLite + Postgres), ids, JSON helpers, migrations, `forEachDialect` test harness | exists (schema pending) |
|
||||||
| `packages/plugin-sdk` | plugin contract, `SourceRecord`, http/rate-limit helpers, fixture test harness | planned |
|
| `packages/plugin-sdk` | plugin contract, `SourceRecord`, http/rate-limit helpers, fixture test harness | planned |
|
||||||
| `plugins/<id>` | one source integration each (hardcover, trakt, mangabaka, listenbrainz, lastfm, csv) | planned |
|
| `plugins/<id>` | one source integration each (hardcover, trakt, mangabaka, listenbrainz, lastfm, csv) | planned |
|
||||||
| `docs/design/mockups` | approved UI mockups (reference only) | exists |
|
| `docs/design/mockups` | approved UI mockups (reference only) | exists |
|
||||||
|
|
||||||
## Data flow
|
## Data flow
|
||||||
Planned sync lifecycle:
|
HTTP request (`apps/server`):
|
||||||
1. The scheduler enqueues a job.
|
1. `Bun.serve` routes `/api/*` and `/ingest/*` to `app.fetch`; everything else gets the web app (HTML import).
|
||||||
2. The worker loads the plugin and calls `pull` or `pullChanges` with an SDK context.
|
2. `apiHandler` matches method + path in the route table, applies the per-IP rate limit (login/register), authenticates
|
||||||
3. The plugin returns `SourceRecord[]`.
|
(`session` cookie `stt_session`, Bearer app token with a scope, or either), checks `X-Requested-With: sttracker` on
|
||||||
4. The core stores a new `source_record_versions` row for each changed hash and diffs the records into `change_events`.
|
cookie-authenticated writes, reads the body (JSON ≤ 1 MB, multipart ≤ 50 MB) and validates it with zod.
|
||||||
5. Each event is either auto-applied (new record, or a field never edited by the user) or sent to the inbox.
|
3. The handler calls one core function with the caller's `userId` and actor `"user"`. Core enforces ownership
|
||||||
6. Every user-layer write appends to `user_history`.
|
(`NotFoundError` → 404). Errors become `{ error: { code, message } }`; unknown errors are logged and return 500 `internal`.
|
||||||
|
4. `POST sources/:id/run|import` only enqueue a `sync` job (lockKey `sync:<id>`, 409 when held); the worker runs it.
|
||||||
|
|
||||||
|
Sync lifecycle (`packages/core/src/sync/`; steps 1–2 are `jobs.ts` and `connections.ts`):
|
||||||
|
1. The scheduler enqueues a `sync` job (lockKey `sync:<connectionId>`) for each non-paused connection that is due.
|
||||||
|
2. The worker claims the job; `syncHandler` loads the connection and plugin and calls `runSync(deps, plugin, connectionId, mode)`, injecting
|
||||||
|
`makeContext` (http client, credentials, cursor). `mode` picks `pull`, `pullChanges` or `importFile`.
|
||||||
|
3. `runSync` opens a `source_runs` row and iterates the plugin's `SourceRecord`s one at a time.
|
||||||
|
- `listen` records are batched into `ingestListens` (insert-or-ignore into `listens`; no snapshot, no events).
|
||||||
|
- Every other record goes through `applyRecord`: hash its normalised form; same hash as
|
||||||
|
`source_records.current_hash` → only `last_seen` moves. Otherwise a new `source_record_versions` row
|
||||||
|
(gzipped `{normalized, raw}`) is written, and:
|
||||||
|
- **new record**: `matchWork` (own transaction, result committed on the record row so retries reuse it),
|
||||||
|
then in one apply transaction create/patch the entry, engagement (de-duplicating plays across sources)
|
||||||
|
or list, and write an `added` `auto_applied` event;
|
||||||
|
- **changed record**: fields that differ from the previous version are decided against `field_sources`
|
||||||
|
→ applied (`changed` `auto_applied`) or filed as `pending` `changed`/`conflict` events. Mapped records
|
||||||
|
are updated through `source_records.target_*`, never re-matched;
|
||||||
|
- `deleted: true` → `pending` `removed`; a new `mergedIntoKey` → `pending` `merged`.
|
||||||
|
4. After a `full` pull that completed and applied every record, records not seen are marked removed
|
||||||
|
(`source_records.deleted_at` + `pending` `removed`).
|
||||||
|
5. The run's counts, log and error land in `source_runs`; the connection gets `last_run_at` and `status`.
|
||||||
|
6. The user resolves `pending` events with `resolveChange` (accept / ignore / keep_mine).
|
||||||
|
7. Every user-layer write appends to `user_history` with the plugin actor `plugin:<pluginId>:<connectionId>`.
|
||||||
|
|
||||||
## External dependencies
|
## External dependencies
|
||||||
- Database: SQLite file under `DATA_DIR` (default) or Postgres via `DATABASE_URL`. If unavailable, nothing works.
|
- Database: SQLite file under `DATA_DIR` (default) or Postgres via `DATABASE_URL`. If unavailable, nothing works.
|
||||||
|
|||||||
@@ -4,6 +4,281 @@ Append-only. Never edit or delete an entry. Supersede it with a new one that lin
|
|||||||
One entry per decision a future maintainer would otherwise have to reverse-engineer.
|
One entry per decision a future maintainer would otherwise have to reverse-engineer.
|
||||||
No index: `grep -n '^## ' docs/DECISIONS.md` lists every entry (date, title, line number).
|
No index: `grep -n '^## ' docs/DECISIONS.md` lists every entry (date, title, line number).
|
||||||
|
|
||||||
|
## 2026-10-30: Deployment: one image on `oven/bun:1.3`, source run directly, compose with an optional Postgres profile
|
||||||
|
**Status:** accepted
|
||||||
|
**Context:** Task 19 packages the server. `Bun.serve` bundles the `apps/web` HTML import at runtime, so there is no build step to ship.
|
||||||
|
**Decision:**
|
||||||
|
- The image is `oven/bun:1.3` (tag, not digest) running `bun apps/server/src/main.ts` from source, as the non-root `bun` user.
|
||||||
|
It installs production dependencies only and copies `apps/web` too, because the server bundles it at startup.
|
||||||
|
- `/data` is the volume (`DATA_DIR=/data`): SQLite file, attachments, backups, user plugins. It is created and owned by `bun` in the image.
|
||||||
|
- `docker-compose.yml` has the `sttracker` service (`./data:/data`, `env_file: .env`) and a `postgres:17-alpine` service under
|
||||||
|
the `postgres` profile with a named volume. SQLite stays the default.
|
||||||
|
- CI is `.gitea/workflows/ci.yml` (GitHub-compatible syntax): typecheck, then `bun test` against a Postgres service, plus the `AGENTS.md` 150-line guard.
|
||||||
|
**Alternatives:** A compiled single binary (`bun build --compile`) was rejected: user plugins load from disk at runtime and the web bundle is built on demand.
|
||||||
|
**Consequences:** Image start time includes bundling the web app. Agent permission files for the new Docker commands are pending the owner (`docs/TODO.md`).
|
||||||
|
|
||||||
|
## 2026-10-30: HTTP API: auth modes, CSRF header, error mapping, actor
|
||||||
|
**Status:** accepted
|
||||||
|
**Context:** Task 16 (`apps/server`).
|
||||||
|
**Decision:**
|
||||||
|
- **Auth modes per route:** `none`, `session` (cookie only: tokens, sources create/edit/delete, device auth, bundle import,
|
||||||
|
admin), `token:<scope>`, `any:<scope>` (cookie or Bearer). A Bearer token valid for another scope gets 403, an unknown or
|
||||||
|
revoked one 401. App tokens cannot mint tokens or handle source credentials.
|
||||||
|
- **CSRF:** cookie-authenticated non-GET requests must send `X-Requested-With: sttracker` (403 otherwise). Browsers cannot
|
||||||
|
add it cross-site without a CORS preflight, which the server never grants. Bearer requests are exempt. Cookie is
|
||||||
|
`stt_session`, HttpOnly, SameSite=Lax, Secure when `PUBLIC_URL` is https, 30 days.
|
||||||
|
- **Error mapping:** zod and `ValidationError` → 400 `validation` (`field: reason`); `AuthError` →
|
||||||
|
401 (`invalid_credentials`, `unauthenticated`), 403 (`forbidden`), 400 with its own code (`invite_required`,
|
||||||
|
`username_taken`, `invalid_username`); `NotFoundError` → 404 `not_found`; `ImportError` → 400 with its code (409 for
|
||||||
|
`account_not_empty`, `conflict`); unique violations → 409 `conflict`; body over the cap → 413 `validation`;
|
||||||
|
rate limit → 429 `rate_limited`; anything else → 500 `internal` with a fixed message, detail logged only.
|
||||||
|
- **Limits:** JSON bodies 1 MB, multipart 50 MB (counted while streaming, not trusted from Content-Length).
|
||||||
|
Login and register: 10 per minute per client IP, in process (resets on restart; not shared across instances).
|
||||||
|
- **Actor:** every API write uses actor `"user"`, including token-authenticated ones, because the change engine only
|
||||||
|
protects fields whose `field_sources` is `"user"`. A `token:<name>` actor would let the next sync overwrite a script's edit.
|
||||||
|
- **Daily backup:** queued for the next 03:00 local with lockKey `"backup"` at boot, 5 s after each run (the finishing job
|
||||||
|
still holds the lock while it runs), and hourly as a safety net. All three are the same idempotent `enqueue`.
|
||||||
|
- **Deleted connections** (`paused` with no credentials) are hidden from `GET sources` and 404 on every `sources/:id` route.
|
||||||
|
- **File imports** travel in the job payload as base64 (`{ connectionId, file: { name, base64 } }`); `syncHandler` decodes
|
||||||
|
them into `runSync`'s `{ file }` mode, recorded as run mode `file`.
|
||||||
|
- **OpenAPI** 3.1 is generated from the route table (`z.toJSONSchema`, input side, unrepresentable → any): every route
|
||||||
|
with method, path, summary, security and request schema. Responses are not typed yet.
|
||||||
|
|
||||||
|
## 2026-10-30: Job failure semantics and plugin reload limits
|
||||||
|
**Status:** accepted
|
||||||
|
**Context:** Task 9 fix round 1.
|
||||||
|
**Decision:**
|
||||||
|
- **Failures before `runSync` never retry.** A missing/paused connection drops the job (`DropJobError`: `done`, message in
|
||||||
|
`last_error`). A missing plugin or undecryptable credentials (`CryptoError`) insert a failed `source_runs` row, set the
|
||||||
|
connection to `error`, advance `last_run_at` and throw `NonRetryableError` (job `failed`, no backoff). The scheduler still
|
||||||
|
picks `error` connections, one interval later, so fixing the cause heals them.
|
||||||
|
- **Stale locks.** Every worker tick re-queues `running` jobs whose `locked_at` is over an hour old (attempts unchanged), so a
|
||||||
|
crash cannot hold a lockKey forever. Job bookkeeping errors are logged (`[jobs]`), never unhandled; `stop()` never rejects
|
||||||
|
and aborts the `signal` passed to handlers (`handler(payload, { signal })`).
|
||||||
|
- **Reload** evicts the entry file from `require.cache` and imports with `?v=<Date.now()>` (Bun ignores the query alone for
|
||||||
|
cached `.ts`). Only entry files reload: changes in a plugin's other modules need a server restart.
|
||||||
|
- `credentials.save` and `updateConnection` read-merge-write in one transaction (row lock on Postgres). `ctx.log` is buffered and
|
||||||
|
appended to the run's `source_runs.log` when the run ends.
|
||||||
|
|
||||||
|
## 2026-10-30: Jobs, plugin registry, connections: rate limit in the manifest, secret split, per-connection lock
|
||||||
|
**Status:** accepted
|
||||||
|
**Context:** Task 9 (`packages/core/src/{jobs,registry,connections}.ts`).
|
||||||
|
**Decision:**
|
||||||
|
- **Rate limit lives in the manifest** (`PluginManifest.rateLimit?: { perMinute, burst? }`), not in plugin code. The host builds
|
||||||
|
`ctx.http` from it (default 60/min, burst 5, UA `STTracker/0.1 (+self-hosted)`), so every plugin is throttled the same way
|
||||||
|
and the per-plugin `RATE_PER_MINUTE` constants are gone.
|
||||||
|
- **Secrets are split off config.** Config fields typed `"secret"` in the manifest are stored only in the encrypted
|
||||||
|
`credentials_enc` blob (with OAuth tokens a plugin saves), never in `config`. `makePluginContext` merges them back into
|
||||||
|
`ctx.config`; `listConnections` returns plain config and `hasCredentials` only. `updateConnection` keeps secrets that are
|
||||||
|
omitted and replaces plain config wholesale. `credentials.save` merges and persists immediately, so a token refreshed
|
||||||
|
mid-pull survives a crash.
|
||||||
|
- **Constructor deviation.** `createConnection`/`updateConnection` take the plugin `manifest` as a trailing argument (needed
|
||||||
|
for the split), and `makePluginContext` is async (it decrypts) and takes the manifest before an optional `http`.
|
||||||
|
- **One sync per connection.** Sync jobs carry `lockKey = "sync:<connectionId>"`; `enqueue` returns null while a
|
||||||
|
`queued|running` job holds the key. On Postgres the check and insert run under `pg_advisory_xact_lock(hashtext(key))`.
|
||||||
|
Claiming is `update … where status = 'queued'` checked on affected rows. Retries back off `2^attempts` minutes, 5 attempts, then `failed`.
|
||||||
|
- **Mode choice.** The sync handler runs `full` when asked, when the plugin lacks `pullChanges`, or when no `ok` full run started
|
||||||
|
within 7 days; else `changes`. A failed run is rethrown so the worker retries it.
|
||||||
|
**Consequences:** Reload re-imports plugin modules with `?v=<Date.now()>`; old module instances are not freed by Bun.
|
||||||
|
|
||||||
|
|
||||||
|
## 2026-10-30: Sync engine: hash composition, anti-flip-flop, no revive, root ancestor, dedupe
|
||||||
|
**Status:** accepted
|
||||||
|
**Context:** Task 8 built the change engine (`packages/core/src/sync/`). Spec §8.3 fixes the outcomes; these
|
||||||
|
are the mechanics a maintainer would otherwise reverse-engineer.
|
||||||
|
**Decision:**
|
||||||
|
- **Hash composition.** sha256 of the stable-key-ordered JSON of `{kind, work, entry|engagement|listen|list,
|
||||||
|
deleted, mergedIntoKey}`. `key` and `raw` are excluded; `deleted` is always a boolean and `mergedIntoKey`
|
||||||
|
always present (null), so an omitted flag and an explicit `false` hash alike. A version row stores
|
||||||
|
base64(`Bun.gzipSync`) of `{normalized, raw}`: the normalised record is what the next change is diffed
|
||||||
|
against (field-level diff = previous version vs. incoming), the raw payload is kept for the record.
|
||||||
|
- **Per-field decision.** Only fields that differ from the previous version are considered, and of those
|
||||||
|
only values that differ from the current STTracker value. `field_sources[f]` unset or this connection's
|
||||||
|
actor → applied; `"user"` → `pending changed {field, from: current, to: incoming}`; another source →
|
||||||
|
`pending conflict {field, current, currentSource, incoming, incomingSource}`. A new record landing on an
|
||||||
|
entry that already exists goes through the same per-field decision, so a first sync never overwrites
|
||||||
|
what the user or another source wrote. Unchanged values are never sent, so a plugin never takes
|
||||||
|
ownership of a field it did not change.
|
||||||
|
- **Anti-flip-flop and superseding.** A pending `changed`/`conflict` for the same (record, field, incoming
|
||||||
|
value) is never filed twice. A different newer value for the same (record, field) resolves the older
|
||||||
|
pending event as `ignored` with `payload.supersededBy = <new event id>`, so at most one is pending per
|
||||||
|
field. If the source comes round to the value STTracker already holds, its pending events for that field
|
||||||
|
are resolved `ignored` with `payload.reason = "source_agrees"`. `keep_mine` re-stamps
|
||||||
|
`field_sources[f] = "user"`, so later values from any source become a user-decided `changed`, never
|
||||||
|
another source-vs-source conflict.
|
||||||
|
- **No revive.** If the mapped entry/engagement is soft-deleted, the record is still snapshotted but nothing
|
||||||
|
is applied and no event is filed; a new record on a work whose entry the user deleted adds nothing.
|
||||||
|
Only the user revives.
|
||||||
|
- **Root ancestor.** Creating an entry on a child work also creates a `status "none"` entry (plugin actor,
|
||||||
|
empty `field_sources`) on the root ancestor work, unless the user has one, live or deleted.
|
||||||
|
- **Mapped records are never re-matched.** Only new records go through `matchWork`. Its result is committed
|
||||||
|
at once as the record's row (`current_hash ""`, `target_entity "work"`), so a failed apply is retried
|
||||||
|
against the same work and an ID-less hint never leaks a second draft. A changed work hint refreshes the
|
||||||
|
mapped work via `updateWorkFromHint` (shared works only). `moveEntryToWork` re-points `source_records`
|
||||||
|
from a merged-away entry to the survivor in the same transaction, so a user's merge never orphans a source.
|
||||||
|
- **Engagement dedupe.** Among live engagements on the entry not already sourced by this connection:
|
||||||
|
finish dates match when both are datetimes ≤ 24 h apart as instants, or when both are day precision and
|
||||||
|
their dates (first 10 chars, computed with `Date.UTC`, no timezone shift) are ≤ 1 day apart; or start
|
||||||
|
datetimes are the same instant. A match attaches an `engagement_sources` row and fills fields that are
|
||||||
|
empty and that no source has set. Every other differing field goes through the per-field decision
|
||||||
|
(so a disagreeing rating is a `conflict`), except the date that identified the play, whose difference is
|
||||||
|
within the tolerance by definition.
|
||||||
|
- **Removal.** Absence counts only after a `full` pull whose iteration finished and whose records all
|
||||||
|
applied without error; any record error fails the run and skips removal. A removed record that comes
|
||||||
|
back clears `deleted_at` and retires its still-pending `removed` event as `ignored`. A merged-away record
|
||||||
|
(`merged_into_key` set, or a pending `merged` event) is expected to vanish: no removal, ever. Accepting
|
||||||
|
`removed` on an engagement other sources still report only deletes this connection's
|
||||||
|
`engagement_sources` row; the engagement is soft-deleted when no source remains. Run counts include only
|
||||||
|
records whose transaction committed.
|
||||||
|
- **Listens skip snapshots.** Listen records are not stored as `source_records` (they are high-volume and
|
||||||
|
append-only). `listens` insert-or-ignore is the idempotence; they are never removed. Per batch of 500:
|
||||||
|
one lookup of existing keys, each distinct recording resolved once, one multi-row insert.
|
||||||
|
- **Lists.** A new `list` record creates a user list (kind `list`, description `Imported from <plugin id>`),
|
||||||
|
items matched outside the transaction (an ID-less item first reuses the user's own private work with the
|
||||||
|
same media type and title). Lists have no `field_sources`; ownership is derived from `user_history`:
|
||||||
|
`name`/`description` belong to whoever last changed them, `items` to the user once any user edit exists.
|
||||||
|
A user-owned field becomes a pending `changed` (for items, the whole incoming item set); otherwise the
|
||||||
|
fields are applied and items replaced (`replaceListItems`, one history row).
|
||||||
|
- **Merge accept.** The target work is the merge target record's mapped entry's work, else `matchWork` on
|
||||||
|
its latest hint. Admin-merge events (`toWorkId`, no connection) resolve through the same `resolveChange`.
|
||||||
|
- **Gone targets.** Accept/keep_mine on an event whose target is deleted (or merged away with no live
|
||||||
|
survivor, found through the user's work alias) resolves it `ignored` with `payload.reason = "target_gone"`.
|
||||||
|
|
||||||
|
**Alternatives:**
|
||||||
|
- Diffing against the live entry instead of the previous version would re-file decided conflicts each run.
|
||||||
|
- Hashing `raw` would turn every cosmetic API change into a new version.
|
||||||
|
- Keeping one run "ok" despite failing records risks marking those records removed.
|
||||||
|
|
||||||
|
**Consequences:** Concurrent runs of one connection are not locked here (Task 9's worker; `docs/TODO.md`).
|
||||||
|
|
||||||
|
## 2026-10-30: Export format `sttracker-export/v1`: per-user zip, camelCase JSONL, deleted rows included, no secrets
|
||||||
|
**Status:** accepted
|
||||||
|
**Context:** Task 15 (`packages/core/src/{export,backup}.ts`) fixed the on-disk bundle format that backups and restore share.
|
||||||
|
**Decision:**
|
||||||
|
- **Files.** A zip (`fflate`) holding `manifest.json` (`{ format, exportedAt, user: { username, displayName } }`),
|
||||||
|
`works.jsonl`, `entries.jsonl`, `engagements.jsonl`, `listens.jsonl`, `lists.jsonl` (items nested),
|
||||||
|
`copies.jsonl`, `custom_fields.jsonl` (field definitions), `history.jsonl`, `attachments.jsonl` (links nested)
|
||||||
|
and `attachments/<sha256>` bytes. One JSON object per line, `\n` separated.
|
||||||
|
- **Keys.** camelCase; JSON columns are parsed into real JSON; `favorite` is a boolean. `user_id` columns are omitted
|
||||||
|
(the importing user owns everything). `works.jsonl` adds `private` and `externalIds`.
|
||||||
|
- **Deleted rows are included** with their `deletedAt`, and `fieldSources`/timestamps are kept, so a restore is lossless.
|
||||||
|
- **No secrets.** No users row beyond username/display name, no sessions, tokens, connections, credentials or source snapshots.
|
||||||
|
- **Merges and aliases.** `works.jsonl` rows carry `mergedIntoId` (the survivor and ancestors are exported too) and
|
||||||
|
`work_aliases.jsonl` holds the user's `{ fromWorkId, toWorkId }` rows. Both are additive to v1. Restore re-creates
|
||||||
|
tombstones (for works it creates) and aliases, so a later write to the old work still resolves to the moved entry.
|
||||||
|
- **Export is one snapshot:** all reads run in a single transaction (repeatable read on Postgres).
|
||||||
|
- **Restore** (`importUserBundle`) needs an account with no entries (`account_not_empty`) and `format === "sttracker-export/v1"`
|
||||||
|
(`unsupported_format`). Works with external IDs are re-matched (else created shared); works without become private.
|
||||||
|
Ids are remapped (UUIDs inside history `before`/`after` and custom-field value keys too). Rows are inserted directly, so no
|
||||||
|
new history is written. The import validates first (attachment mime/size/sha256, rating 0-100, status, visibility, 2 GB
|
||||||
|
uncompressed cap) and writes all rows, attachments included, in one transaction; errors are
|
||||||
|
`invalid_attachment`, `corrupt_bundle`, `bundle_too_large`, `conflict`. Works merged away at export time are exported as the tombstone row with its `mergedIntoId`.
|
||||||
|
- **Backups** write `<dataDir>/backups/<ISO with ':'->'-'>/<username>.zip` per user plus `sttracker.db` (`VACUUM INTO`, SQLite only),
|
||||||
|
are built in `<stamp>.tmp` then renamed (modes 0700/0600), and prune to the newest `keep` (default 14).
|
||||||
|
**Consequences:** Any change to these files after release needs `v2` and a migration path for old bundles.
|
||||||
|
|
||||||
|
## 2026-10-30: User layer: per-user moves merge entries, keep aliases, and roll the library up by hierarchy
|
||||||
|
**Status:** accepted
|
||||||
|
**Context:** Task 6 built the user layer (`packages/core/src/{entries,engagements,history,lists,copies}.ts`).
|
||||||
|
Several behaviours are not obvious from the spec and would otherwise have to be reverse-engineered.
|
||||||
|
**Decision:**
|
||||||
|
- **Merge on move.** `moveEntryToWork` onto a work where the user already has an entry merges the two:
|
||||||
|
- Live target: the target's values win. Empty target fields are filled from the moved entry, carrying
|
||||||
|
the moved entry's `field_sources`. Tags are unioned and custom fields and overrides merged; their
|
||||||
|
`field_sources` is `"user"` if either side's was, otherwise the target's.
|
||||||
|
- Differing non-empty `review`/`private_notes` are concatenated (target, `\n\n---\n\n`, moved).
|
||||||
|
- A conflicting rating keeps the target's; the moved rating is recorded in the history row's `before`.
|
||||||
|
- Engagements are re-parented, each with an `entryId` history row (restorable), and the moved entry is
|
||||||
|
soft-deleted.
|
||||||
|
- Soft-deleted target: revived with the moved entry's values and `field_sources` as-is.
|
||||||
|
- **Alias bookkeeping.** A move writes `work_aliases (user, old → target)`, re-points aliases that led to
|
||||||
|
the old work, drops any alias leaving the target (no cycles), tombstones the user's own private draft,
|
||||||
|
and re-points this user's copies (with history) and list items. Writes to an aliased or merged work
|
||||||
|
(`upsertEntry`, copies, list items) resolve to the target. **Undoing a move** via `restoreVersion` deletes
|
||||||
|
the forward alias and writes no reverse alias, so later syncs onto the target land on the target.
|
||||||
|
- **Library cursor.** `listEntries` cursors are base64 JSON `{ sort, sortValue, id }`. The sort is carried
|
||||||
|
so a cursor cannot be replayed under another sort. For the title sort, `sortValue` is the SQL
|
||||||
|
`lower(w.title)` of the last row, never a JS lowercase, so SQL and cursor agree on non-ASCII titles.
|
||||||
|
- **Custom fields patches replace the whole map** (`customFields` in an entry, engagement or copy patch).
|
||||||
|
This makes restore exact. `field_sources` tracks the key `customFields` as a whole. Restore does not
|
||||||
|
re-check values against today's definitions, because it is the user's own past data.
|
||||||
|
- **List items are hard-deleted.** `list_items` has no `deleted_at`. The list's `user_history` snapshot
|
||||||
|
holds the ordered items, so `restoreVersion` brings removed items back.
|
||||||
|
- **Hierarchy visibility.** `childSummary` counts the user's live entries up to three levels below the
|
||||||
|
entry's work (child, grandchild, great-grandchild). The default library view (`parentId: null`) lists
|
||||||
|
entries with no ancestor, up to three levels, on which the user has a live entry. An episode whose show
|
||||||
|
is untracked therefore stays visible, and is rolled up once the show gets an entry. Drilling in
|
||||||
|
(`parentId: X`) applies the same rule relative to X: descendants up to three levels with no live-entry
|
||||||
|
work strictly between them and X. `childSummary` still counts every descendant entry.
|
||||||
|
- **Concurrent creates.** `upsertEntry` inserts with `ON CONFLICT (user_id, work_id) DO NOTHING`, then
|
||||||
|
patches through the update path, so a concurrent creator never aborts the caller's transaction.
|
||||||
|
|
||||||
|
**Alternatives:**
|
||||||
|
- Refusing a move onto an existing entry pushes the merge onto the user.
|
||||||
|
- Recursive CTEs for hierarchy are portable but heavier; three levels cover show → season → episode.
|
||||||
|
- A JS-lowercased cursor breaks on SQLite, whose `lower()` is ASCII-only.
|
||||||
|
|
||||||
|
**Consequences:** Undoing a move does not move copies or list items back, and hierarchy deeper than three
|
||||||
|
levels is not rolled up (both in `docs/TODO.md`).
|
||||||
|
|
||||||
|
## 2026-10-30: Catalogue matching is race-safe through unique keys plus whole-transaction retry
|
||||||
|
**Status:** accepted
|
||||||
|
**Context:** Two syncs matching the same new work at once both miss the lookup and both insert. On
|
||||||
|
Postgres the loser hit the `external_ids (namespace, value)` unique key and crashed the sync; seasons and
|
||||||
|
episodes had no key at all and were duplicated.
|
||||||
|
**Decision:**
|
||||||
|
- Migration `0002_works_position_unique` adds a unique index on `works (parent_id, position)`. NULLs are
|
||||||
|
distinct on both dialects, so top-level works and position-less children are unaffected.
|
||||||
|
- `matchWork` re-runs the whole resolve in a fresh transaction on a unique violation (SQLite
|
||||||
|
"UNIQUE constraint failed", Postgres SQLSTATE 23505), up to 3 attempts. Postgres aborts the transaction
|
||||||
|
on the error, so a statement-level retry is not possible.
|
||||||
|
- Adding missing external IDs to an existing work uses `ON CONFLICT DO NOTHING`.
|
||||||
|
- An ID match only counts if the work is shared or owned by the matching user, and private works can
|
||||||
|
never carry external IDs (`createWork` refuses; global merges accept only shared works).
|
||||||
|
- A global merge repoints children of `from` to `to` unless `to` already has a child at that position.
|
||||||
|
|
||||||
|
**Alternatives:**
|
||||||
|
- Advisory or table locks: dialect-specific, and SQLite already serialises writes.
|
||||||
|
- Upserts throughout `createWork`: would hide genuine conflicts and need per-dialect `RETURNING` handling.
|
||||||
|
|
||||||
|
**Consequences:** Parents resolved without IDs or position (title/year reuse) still have no unique key
|
||||||
|
and can be duplicated under concurrency; tracked in `docs/TODO.md`.
|
||||||
|
|
||||||
|
## 2026-10-30: A single Kysely dialect over Bun.SQL for SQLite and Postgres
|
||||||
|
**Status:** accepted
|
||||||
|
**Context:** Kysely ships no driver for Bun. `Bun.SQL` speaks both `sqlite://` and `postgres://` behind one API.
|
||||||
|
**Decision:**
|
||||||
|
- `packages/db/src/dialect.ts` is one Kysely `Dialect` whose driver wraps `new SQL(url)`, choosing Kysely's
|
||||||
|
SQLite or Postgres adapter, compiler and introspector by URL scheme.
|
||||||
|
- Postgres connections come from `sql.reserve()`. Bun's SQLite adapter does not support `reserve()`, so SQLite
|
||||||
|
uses the single `SQL` instance as its only connection, serialised by a mutex so transactions never interleave.
|
||||||
|
- Transactions are plain `begin`/`commit`/`rollback` on the held connection.
|
||||||
|
|
||||||
|
**Alternatives:**
|
||||||
|
- Separate dialects over `bun:sqlite` and `Bun.sql`: two drivers to maintain for the same job.
|
||||||
|
- Community Bun dialect packages: an extra dependency, and none covers both databases.
|
||||||
|
|
||||||
|
**Consequences:** SQLite is effectively single-connection (fine for a self-hosted app). Savepoints and real
|
||||||
|
streaming are not implemented; `streamQuery` returns the whole result as one chunk.
|
||||||
|
|
||||||
|
## 2026-10-30: JSON columns are stored as text on both dialects
|
||||||
|
**Status:** accepted
|
||||||
|
**Context:** Spec §11 calls for `jsonb`. SQLite has no JSON type, so `jsonb` on Postgres would need
|
||||||
|
per-dialect read/write code in every service that touches a JSON column.
|
||||||
|
**Decision:** JSON columns are `text` everywhere, written with `toJson()` and read with `fromJson()` from
|
||||||
|
`@sttracker/db/json`. `fromJson` returns a caller-supplied fallback for null or invalid text.
|
||||||
|
**Alternatives:** `jsonb` on Postgres with per-dialect read/write code.
|
||||||
|
**Consequences:** No JSON operators or indexes in SQL; filtering on JSON content happens in app code. Can be
|
||||||
|
superseded later by a migration to `jsonb` if a query needs it.
|
||||||
|
|
||||||
|
## 2026-10-30: UUIDv7 instead of ULID for IDs
|
||||||
|
**Status:** accepted
|
||||||
|
**Context:** Spec and `AGENTS.md` say IDs are ULIDs generated in app code. Bun has `Bun.randomUUIDv7()` built in.
|
||||||
|
**Decision:** `newId()` in `@sttracker/db/ids` returns `Bun.randomUUIDv7()`. IDs are stored as `text`.
|
||||||
|
**Alternatives:** ULID, which needs a dependency or hand-written encoder.
|
||||||
|
**Consequences:** IDs are equally time-sortable as strings with no extra dependency. They are 36 characters
|
||||||
|
instead of 26. Where `AGENTS.md` or the spec say "ULID", read "UUIDv7".
|
||||||
|
|
||||||
## 2026-10-30: Product design decisions recorded in spec 001
|
## 2026-10-30: Product design decisions recorded in spec 001
|
||||||
**Status:** accepted
|
**Status:** accepted
|
||||||
**Context:** Brainstorming with the owner produced the v1 design.
|
**Context:** Brainstorming with the owner produced the v1 design.
|
||||||
|
|||||||
@@ -7,8 +7,16 @@ Small, specific, independently actionable. Remove only when actually done.
|
|||||||
- [ ] Choose a linter/formatter (e.g. Biome), then add `lint` to `package.json`, `AGENTS.md` and `docs/PERMISSIONS.md`.
|
- [ ] Choose a linter/formatter (e.g. Biome), then add `lint` to `package.json`, `AGENTS.md` and `docs/PERMISSIONS.md`.
|
||||||
- [ ] Add CI on Gitea Actions: install, typecheck, and test on SQLite and Postgres. Add a guard that fails if `AGENTS.md` is over 150 lines.
|
- [ ] Add CI on Gitea Actions: install, typecheck, and test on SQLite and Postgres. Add a guard that fails if `AGENTS.md` is over 150 lines.
|
||||||
|
|
||||||
|
- [ ] Agent permissions for Task 16 commands: add `bun run typecheck`, `bun apps/server/src/main.ts` and `bun --hot apps/server/src/main.ts` (all verified 2026-10-30) to `docs/PERMISSIONS.md`, `.claude/settings.json`, `.gemini/settings.json`, then fill Typecheck/Dev/Start in `AGENTS.md` Commands. Needs a human: agents may not edit their own permission files.
|
||||||
|
|
||||||
## Next
|
## Next
|
||||||
|
- [ ] API: response schemas in the OpenAPI document (requests only today).
|
||||||
|
- [ ] API: login/register rate limit is per process and keyed on the socket IP; behind a reverse proxy every client shares one key. Add a trusted-proxy setting for `X-Forwarded-For`.
|
||||||
|
- [ ] API: file imports are base64 in the `jobs.payload` row (up to ~67 MB of text). Move uploads to `DATA_DIR/uploads/` and pass a path.
|
||||||
|
- [ ] API: `PATCH list-items/:id` only moves items; editing an item's note needs a core function.
|
||||||
|
- [ ] Connections: a user-paused connection that never had credentials looks deleted (`paused` + no credentials) and disappears from `GET sources`. Add an explicit `deleted_at`.
|
||||||
- [ ] Phase 2 and Phase 3 plans (spec §12).
|
- [ ] Phase 2 and Phase 3 plans (spec §12).
|
||||||
|
- [ ] Generate image thumbnails for attachments (Phase 1 sizes images with CSS).
|
||||||
|
|
||||||
## Someday
|
## Someday
|
||||||
|
|
||||||
@@ -23,4 +31,22 @@ Small, specific, independently actionable. Remove only when actually done.
|
|||||||
file, and compare steps and token use.
|
file, and compare steps and token use.
|
||||||
|
|
||||||
## Known issues
|
## Known issues
|
||||||
|
- [ ] Plugins: reload re-imports entry files only; edits to a plugin's other modules need a server restart.
|
||||||
|
- [ ] Plugins: rate limiting is per run (each run builds its own client), not per plugin (spec §11); share a limiter per plugin in Phase 2.
|
||||||
|
- [ ] Plugins: `ctx.log` reaches `source_runs.log` only after the run ends, so a crash mid-run loses it.
|
||||||
|
- [ ] Sync: two concurrent runs of one connection outside the job queue (e.g. a manual run) can still race on `source_records`.
|
||||||
|
Queued syncs are serialised by `lockKey`.
|
||||||
|
- [ ] Restore: a new child work whose parent+position collides with an existing child of a re-matched parent fails
|
||||||
|
with `ImportError("conflict")` instead of being merged or repositioned.
|
||||||
|
- [ ] Restore: an unlink of identical attachment bytes between the pre-write and the commit could remove the file;
|
||||||
|
mitigated by re-writing after commit, not locked.
|
||||||
|
- [ ] Export bundles hold attachment bytes in memory; very large libraries need a streaming zip.
|
||||||
|
- [ ] Admin merge does not merge colliding children recursively; colliding children stay under the tombstoned parent.
|
||||||
|
- [ ] Matching: a shared parent resolved without external IDs or position (title/year reuse in
|
||||||
|
`packages/core/src/matching.ts`) has no unique key, so concurrent matches can create it twice.
|
||||||
|
- [ ] Library search (`q`) and the title sort use the work's title, not the user's `overrides.title`
|
||||||
|
(overrides are a JSON text column). The `tag` filter matches the JSON text with LIKE.
|
||||||
|
- [ ] Library hierarchy rollup and orphan detection look three levels deep; deeper trees are not rolled up.
|
||||||
|
- [ ] Undoing a per-user move does not move copies or list items back, nor restore aliases that the move
|
||||||
|
re-pointed.
|
||||||
- [ ] `bun run typecheck` fails with TS18003 until source files exist under `apps/`, `packages/` or `plugins/`.
|
- [ ] `bun run typecheck` fails with TS18003 until source files exist under `apps/`, `packages/` or `plugins/`.
|
||||||
|
|||||||
@@ -0,0 +1,642 @@
|
|||||||
|
---
|
||||||
|
status: in-progress
|
||||||
|
---
|
||||||
|
|
||||||
|
# Phase 1, "Own your data": implementation plan
|
||||||
|
|
||||||
|
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task by task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||||
|
|
||||||
|
**Goal:** A self-hosted Bun app where users connect Hardcover, Trakt, MangaBaka, ListenBrainz, Last.fm and CSV/JSON sources. STTracker pulls and snapshots their data, detects platform-side changes into a Changes inbox, and never overwrites what the user wrote. Users can log anything without IDs and export or back up everything. SQLite or Postgres, shipped in Docker.
|
||||||
|
|
||||||
|
**Architecture:** Bun workspaces.
|
||||||
|
- `packages/db`: Kysely over a single custom dialect wrapping `Bun.SQL`, which speaks both `sqlite://` and `postgres://`.
|
||||||
|
- `packages/plugin-sdk`: the plugin contract.
|
||||||
|
- `packages/core`: domain services and the sync/change engine.
|
||||||
|
- `plugins/*`: source integrations. They return `SourceRecord`s and never touch the DB.
|
||||||
|
- `apps/server`: `Bun.serve` API, job worker, and serving the web app.
|
||||||
|
- `apps/web`: React app via HTML imports.
|
||||||
|
|
||||||
|
**Tech stack:** Bun 1.3.14, TypeScript 5.9, Kysely 0.28+, zod 4, fflate, React 19, wouter, @tanstack/react-query.
|
||||||
|
|
||||||
|
**Spec:** `docs/specs/001-sttracker-v1-design.md` (read §6–§11 before any task). Design: `DESIGN.md`, `docs/design/mockups/round2-app-scrapbook-river.html`.
|
||||||
|
|
||||||
|
## Global constraints
|
||||||
|
|
||||||
|
- Follow `AGENTS.md` rules. In particular: Bun-native APIs only; plugins import only `@sttracker/plugin-sdk`; syncs never overwrite user-edited fields.
|
||||||
|
- **Runtime deps are approved for this plan only:**
|
||||||
|
- `kysely`, `zod`, `fflate` (root);
|
||||||
|
- `react`, `react-dom`, `wouter`, `@tanstack/react-query` (apps/web);
|
||||||
|
- `@types/react`, `@types/react-dom` (dev).
|
||||||
|
- Ask before adding anything else.
|
||||||
|
- **IDs:** `newId()` from `@sttracker/db/ids`. It returns `Bun.randomUUIDv7()`. (UUIDv7 replaces the spec's ULID: it is equally time-sortable and built in. This is recorded in DECISIONS by Task 1.)
|
||||||
|
- **Timestamps:** ISO-8601 UTC strings (`new Date().toISOString()`) stored as `text` on both dialects.
|
||||||
|
- **JSON columns:** `text` on both dialects. Use `toJson()`/`fromJson()` from `@sttracker/db/json`. (A simplification of spec §11's jsonb; recorded in DECISIONS by Task 1.)
|
||||||
|
- **Ratings:** integer 0–100 or null.
|
||||||
|
- **Partial dates:** `{ value: string; precision: "day" | "month" | "year" | "unknown" }`. `value` is `YYYY-MM-DD`, `YYYY-MM`, `YYYY` or `""`, or a full ISO datetime when the source gives a time. Partial dates are never converted through `Date`, which would shift them by timezone.
|
||||||
|
- **Status enum:** `"planned" | "in_progress" | "paused" | "completed" | "dropped" | "none"`.
|
||||||
|
- **MediaType enum:** `book comic manga game movie show season episode podcast podcast_episode audio_drama artist album track video other`.
|
||||||
|
- **Package exports:** each package's `package.json` has `"exports": { "./*": "./src/*.ts" }`. Import by path (e.g. `@sttracker/core/entries`). There are no barrel `index.ts` files, which avoids merge conflicts between parallel tasks.
|
||||||
|
- **DB tests** use `forEachDialect()` from `@sttracker/db/testing` and must pass on SQLite always, and on Postgres when `TEST_POSTGRES_URL` is set. Local value: `TEST_POSTGRES_URL=postgres://postgres:[email protected]:55432/postgres` (container `sttracker-pg-test`).
|
||||||
|
- **Tests never hit the network.** Plugins are tested with fixtures in `plugins/<id>/test/fixtures/*.json`, written by hand from the documented API shapes, and played through `createFixtureHttp()`.
|
||||||
|
- **Attribution strings** are shown on the About page (exact text):
|
||||||
|
- "STTracker uses Trakt but is not endorsed or certified by Trakt."
|
||||||
|
- "Manga data from MangaBaka (CC BY-NC-SA 4.0)."
|
||||||
|
- "Music data from ListenBrainz and Last.fm."
|
||||||
|
- "Book data from Hardcover."
|
||||||
|
- **Commits:** Conventional Commits, ending with the line `Co-Authored-By: Claude Opus 5.5 <[email protected]>`.
|
||||||
|
|
||||||
|
## Review focus
|
||||||
|
|
||||||
|
1. **A full pull that fails part-way** (network error, 429s exhausted, thrown parser error) must mark *nothing* as removed. Only a pull that finished emits `removed` events. Test lives in Task 8.
|
||||||
|
2. **Running the same sync twice with unchanged source data** creates zero new `source_record_versions`, `change_events`, `engagements` or `listens`. Test lives in Task 8.
|
||||||
|
3. **A user edits a field, then the source changes it:** the user's value stays and a pending change appears. The same goes for two sources disagreeing: a pending `conflict`, with no flip-flopping on the next run. Test lives in Task 8 on both dialects.
|
||||||
|
4. **Messy CSV input** must round-trip without data loss: UTF-8 BOM, `;` delimiter, quoted newlines and quotes, empty rating, "2019" as a date, emoji titles. Test lives in Task 13.
|
||||||
|
5. **Rotating single-use refresh tokens (Trakt):** refreshed credentials are saved before any further request, and two syncs of the same connection never run at once. Tests live in Task 10 (save-before-continue) and Task 9 (connection lock).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Dependency graph and waves
|
||||||
|
|
||||||
|
```
|
||||||
|
Wave A (parallel): T1 db-core T3 plugin-sdk
|
||||||
|
Wave B: T2 schema (needs T1)
|
||||||
|
Wave C (parallel): T4 auth T5 catalogue+matching T6 user-layer T7 attachments+crypto
|
||||||
|
T10 trakt T11 hardcover T12 mangabaka T13 csv/json T14 listenbrainz+lastfm (plugins need only T3)
|
||||||
|
Wave D (parallel): T8 sync engine (T3,T5,T6) T9 jobs+registry (T2,T3,T7) T15 export/backup (T5,T6,T7)
|
||||||
|
Wave E: T16 server API (all core)
|
||||||
|
Wave F (parallel): T17 web shell+library+detail T18 web changes/sources/lists/owned/settings/export T19 docker+CI+docs
|
||||||
|
```
|
||||||
|
|
||||||
|
Parallel tasks run in separate git worktrees branched from `feat/phase1` and are merged back into it after review.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 1: `packages/db`: dialect, ids, json, migrations runner, test harness
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Create: `packages/db/package.json` (`@sttracker/db`, deps `kysely`), `packages/db/src/dialect.ts`, `packages/db/src/db.ts`, `packages/db/src/ids.ts`, `packages/db/src/json.ts`, `packages/db/src/migrate.ts`, `packages/db/src/testing.ts`
|
||||||
|
- Create: workspace `package.json` stubs with `exports` for `packages/core` (`@sttracker/core`), `packages/plugin-sdk` (`@sttracker/plugin-sdk`), `apps/server` (`@sttracker/server`), `apps/web` (`@sttracker/web`). Add all approved deps to the stubs now (`bun add` in each), so later parallel tasks never touch `bun.lock`.
|
||||||
|
- Modify: `docs/DECISIONS.md` (UUIDv7, JSON as text, single Bun.SQL dialect)
|
||||||
|
- Test: `packages/db/test/db.test.ts`
|
||||||
|
|
||||||
|
**Interfaces (produces):**
|
||||||
|
- `type Dialect = "sqlite" | "postgres"`
|
||||||
|
- `createDb(url: string): Promise<Db>`, where `interface Db { k: Kysely<Database>; dialect: Dialect; close(): Promise<void> }`. `url` is `sqlite://<path>`, `sqlite://:memory:` or `postgres://…`. `Database` is imported from `./schema` (Task 2). Until Task 2 lands, use a placeholder `export interface Database {}` in `src/schema.ts`.
|
||||||
|
- `newId(): string`, `nowIso(): string`
|
||||||
|
- `toJson(v: unknown): string`, `fromJson<T>(s: string | null | undefined, fallback: T): T`
|
||||||
|
- `migrateToLatest(db: Db): Promise<void>` uses Kysely `Migrator` with migrations imported statically from `src/migrations/index.ts` (`export const migrations: Record<string, Migration>`).
|
||||||
|
- `forEachDialect(name: string, fn: (getDb: () => Db) => void): void` registers a `describe` per dialect.
|
||||||
|
- Before each test it builds a fresh migrated DB: SQLite `:memory:`, or on Postgres a fresh `create database st_<random>` derived from `TEST_POSTGRES_URL`.
|
||||||
|
- It closes the DB after each test, and drops the Postgres DB in `afterAll`.
|
||||||
|
|
||||||
|
- [ ] **Step 1: Write failing tests** in `db.test.ts` using `forEachDialect("db basics", …)`:
|
||||||
|
- `"round-trips a row"`: create a temp table via `sql`, insert `{id: newId(), j: toJson({a: 1})}`, select, `expect(fromJson(row.j, {})).toEqual({a: 1})`.
|
||||||
|
- `"transactions roll back"`: `await expect(db.k.transaction().execute(async tx => { insert…; throw new Error("x") })).rejects.toThrow("x")`, then count is 0.
|
||||||
|
- `"newId is time-sortable"`: 1000 ids are already sorted.
|
||||||
|
- [ ] **Step 2:** Run `bun test packages/db` → FAIL (module not found).
|
||||||
|
- [ ] **Step 3: Implement.**
|
||||||
|
- `dialect.ts`: a Kysely `Dialect` whose `Driver` wraps `new SQL(url)`. `acquireConnection` returns a connection from `sql.reserve()`. `executeQuery` calls `conn.unsafe(compiled.sql, compiled.parameters)` and maps it to `{ rows, numAffectedRows }`. Transactions use `BEGIN`/`COMMIT`/`ROLLBACK` on the reserved connection.
|
||||||
|
- Adapter/compiler/introspector: `SqliteAdapter`/`SqliteQueryCompiler` for sqlite, and `PostgresAdapter`/`PostgresQueryCompiler` for postgres.
|
||||||
|
- For sqlite, run `PRAGMA foreign_keys = ON; PRAGMA journal_mode = WAL;` on create (skip WAL for `:memory:`).
|
||||||
|
- [ ] **Step 4:** `bun test packages/db` → PASS. Also run `TEST_POSTGRES_URL=… bun test packages/db` → PASS on both.
|
||||||
|
- [ ] **Step 5:** Commit `feat(db): Bun.SQL Kysely dialect for sqlite and postgres`.
|
||||||
|
|
||||||
|
### Task 2: Schema migration and `Database` types
|
||||||
|
|
||||||
|
**Files:** Create `packages/db/src/schema.ts` (Kysely table interfaces) and `packages/db/src/migrations/0001_initial.ts`; modify `packages/db/src/migrations/index.ts`. Test: `packages/db/test/schema.test.ts`.
|
||||||
|
|
||||||
|
**Interfaces (produces):** the `Database` interface with these tables. Column names are snake_case. Every table has `id text pk` unless noted. `ts` means ISO text. JSON is text.
|
||||||
|
- `users`(username unique, display_name, password_hash, role `admin|member`, preferences json, created_at, updated_at)
|
||||||
|
- `sessions`(user_id fk, token_hash unique, expires_at, created_at)
|
||||||
|
- `invites`(code_hash unique, created_by fk, used_by nullable, expires_at, created_at)
|
||||||
|
- `app_tokens`(user_id, name, token_hash unique, scopes json `("read"|"write"|"ingest")[]`, last_used_at, revoked_at, created_at)
|
||||||
|
- `works`(media_type, title, original_title, year int null, release_date null, creators json, description, cover_url, cover_attachment_id null, metadata json, parent_id null fk works, position json null, owner_user_id null fk users, merged_into_id null, created_at, updated_at)
|
||||||
|
- `external_ids`(work_id fk, namespace, value). Unique (namespace, value).
|
||||||
|
- `work_aliases`(user_id, from_work_id, to_work_id). Unique (user_id, from_work_id).
|
||||||
|
- `work_history`(work_id, before json, after json, actor, created_at)
|
||||||
|
- `entries`(user_id, work_id, status, rating int null, review null, private_notes null, favorite int 0/1, tags json, custom_fields json, visibility `private|friends|public`, overrides json, media_type_override null, field_sources json, created_at, updated_at, deleted_at null). Unique (user_id, work_id).
|
||||||
|
- `engagements`(entry_id, started_at null, started_precision, finished_at null, finished_precision, progress json, platform null, rating null, notes null, custom_fields json, field_sources json, created_at, updated_at, deleted_at)
|
||||||
|
- `engagement_sources`(engagement_id, connection_id, record_key). Unique (connection_id, record_key).
|
||||||
|
- `listens`(user_id, listened_at, artist, track, album null, duration_ms null, mbids json, work_id null, source, source_key, created_at). Unique (user_id, source, source_key). Index (user_id, listened_at).
|
||||||
|
- `lists`(user_id, name, description, kind `list|grouping|thread`, visibility, rating null, review null, created_at, updated_at, deleted_at)
|
||||||
|
- `list_items`(list_id, work_id, position int, note null)
|
||||||
|
- `custom_field_definitions`(user_id, name, type, options json, applies_to json `MediaType[] | "all"`, level `entry|engagement|copy`, created_at)
|
||||||
|
- `copies`(user_id, work_id null, title_hint null, format null, acquired_from null, acquired_at null, acquired_precision, price_cents int null, currency null, notes null, custom_fields json, source_ref null, created_at, updated_at, deleted_at)
|
||||||
|
- `attachments`(user_id, sha256, mime, size int, filename, caption null, taken_at null, visibility null, created_at)
|
||||||
|
- `attachment_links`(attachment_id, entity `entry|engagement|copy|list|work`, entity_id)
|
||||||
|
- `user_history`(user_id, entity, entity_id, before json, after json, actor, created_at)
|
||||||
|
- `match_suggestions`(user_id, work_id, candidate_work_id, score real, status `pending|accepted|rejected`, created_at)
|
||||||
|
- `source_connections`(user_id, plugin_id, name, config json, credentials_enc null, schedule_minutes int null, cursor null, status `active|paused|error`, last_run_at null, created_at)
|
||||||
|
- `source_runs`(connection_id, mode `full|changes|file`, started_at, finished_at null, status `running|ok|failed`, counts json, log text, error null)
|
||||||
|
- `source_records`(connection_id, key, kind, current_hash, first_seen, last_seen, deleted_at null, merged_into_key null, target_entity null, target_id null). Unique (connection_id, key).
|
||||||
|
- `source_record_versions`(record_id, hash, raw_gz text (base64 of gzip), seen_at)
|
||||||
|
- `change_events`(user_id, connection_id null, run_id null, record_id null, type `added|changed|removed|merged|conflict`, target_entity, target_id null, payload json, status `pending|auto_applied|accepted|ignored|kept_mine`, created_at, resolved_at null)
|
||||||
|
- `jobs`(type, payload json, run_at, status `queued|running|done|failed`, attempts int, last_error null, locked_at null, lock_key null, created_at). Index (status, run_at). Unique partial behaviour for lock_key is enforced in code (Task 9).
|
||||||
|
|
||||||
|
- [ ] **Step 1: Failing test** `forEachDialect("schema")`, `"all tables exist and unique constraints hold"`:
|
||||||
|
- insert a user, a work, and two `external_ids` rows with the same (namespace, value);
|
||||||
|
- expect the second insert to reject;
|
||||||
|
- insert duplicate `entries` (user, work) and expect a reject.
|
||||||
|
- [ ] **Step 2:** `bun test packages/db/test/schema.test.ts` → FAIL.
|
||||||
|
- [ ] **Step 3:** Write the migration using only the Kysely schema builder with portable types (`text`, `integer`, `real`), plus indexes for every fk column.
|
||||||
|
- [ ] **Step 4:** PASS on both dialects.
|
||||||
|
- [ ] **Step 5:** Commit `feat(db): initial schema`.
|
||||||
|
|
||||||
|
### Task 3: `packages/plugin-sdk`: contract, http client, fixture harness
|
||||||
|
|
||||||
|
**Files:** Create `packages/plugin-sdk/src/{types.ts,http.ts,testing.ts,partial-date.ts}`. Tests: `packages/plugin-sdk/test/{http.test.ts,partial-date.test.ts}`.
|
||||||
|
|
||||||
|
**Interfaces (produces):** `types.ts`, exactly these names:
|
||||||
|
```ts
|
||||||
|
export type MediaType = /* enum from Global constraints */;
|
||||||
|
export type Status = "planned" | "in_progress" | "paused" | "completed" | "dropped" | "none";
|
||||||
|
export type Precision = "day" | "month" | "year" | "unknown";
|
||||||
|
export interface PartialDate { value: string; precision: Precision }
|
||||||
|
export type Capability = "pull" | "pullChanges" | "push" | "ownedLibrary" | "ingest" | "lookup" | "importFile" | "exportFile";
|
||||||
|
export interface ConfigField { key: string; label: string; type: "string" | "secret" | "number" | "boolean" | "select"; required?: boolean; options?: string[]; help?: string }
|
||||||
|
export interface PluginManifest { id: string; name: string; version: string; sdkVersion: 1; mediaTypes: MediaType[]; capabilities: Capability[]; config: ConfigField[]; attribution?: string }
|
||||||
|
export interface WorkHint { title: string; mediaType: MediaType; year?: number; creators?: { name: string; role?: string }[]; externalIds: Record<string, string>; coverUrl?: string; description?: string; parent?: WorkHint; position?: { season?: number; episode?: number; index?: number } }
|
||||||
|
export interface EntryFields { status?: Status; rating?: number | null; review?: string | null; notes?: string | null; favorite?: boolean; tags?: string[] }
|
||||||
|
export interface EngagementFields { startedAt?: PartialDate | null; finishedAt?: PartialDate | null; progress?: Record<string, number | string>; platform?: string | null; rating?: number | null; notes?: string | null }
|
||||||
|
export interface ListenFields { listenedAt: string; artist: string; track: string; album?: string; durationMs?: number; mbids?: { recording?: string; release?: string; artist?: string } }
|
||||||
|
interface Base { key: string; raw: unknown; deleted?: boolean; mergedIntoKey?: string }
|
||||||
|
export type SourceRecord =
|
||||||
|
| (Base & { kind: "entry"; work: WorkHint; entry: EntryFields })
|
||||||
|
| (Base & { kind: "engagement"; work: WorkHint; engagement: EngagementFields })
|
||||||
|
| (Base & { kind: "listen"; listen: ListenFields })
|
||||||
|
| (Base & { kind: "list"; list: { name: string; description?: string; items: { work: WorkHint; note?: string }[] } });
|
||||||
|
export interface HttpClient { json<T>(url: string, init?: RequestInit & { query?: Record<string, string | number | undefined> }): Promise<T>; raw(url: string, init?: RequestInit): Promise<Response> }
|
||||||
|
export interface PluginContext {
|
||||||
|
http: HttpClient; log: (msg: string) => void; config: Record<string, unknown>;
|
||||||
|
credentials: { get(): Promise<Record<string, string> | null>; save(c: Record<string, string>): Promise<void> };
|
||||||
|
cursor: { get(): Promise<string | null>; set(c: string): Promise<void> };
|
||||||
|
now(): Date; signal: AbortSignal;
|
||||||
|
}
|
||||||
|
export interface DeviceAuth { start(ctx: PluginContext): Promise<{ userCode: string; verificationUrl: string; deviceCode: string; intervalSec: number; expiresInSec: number }>; poll(ctx: PluginContext, deviceCode: string): Promise<"pending" | Record<string, string>> }
|
||||||
|
export interface SourcePlugin {
|
||||||
|
manifest: PluginManifest;
|
||||||
|
deviceAuth?: DeviceAuth;
|
||||||
|
pull?(ctx: PluginContext): AsyncIterable<SourceRecord>;
|
||||||
|
pullChanges?(ctx: PluginContext): AsyncIterable<SourceRecord>;
|
||||||
|
lookup?(ctx: PluginContext, q: { title: string; mediaType?: MediaType; year?: number }): Promise<WorkHint[]>;
|
||||||
|
importFile?(ctx: PluginContext, file: { name: string; bytes: Uint8Array }): AsyncIterable<SourceRecord>;
|
||||||
|
}
|
||||||
|
export function definePlugin(p: SourcePlugin): SourcePlugin;
|
||||||
|
```
|
||||||
|
- `http.ts`: `createHttpClient(opts: { ratePerMinute?: number; burst?: number; maxRetries?: number; userAgent: string; fetchImpl?: typeof fetch; sleep?: (ms: number) => Promise<void> }): HttpClient`.
|
||||||
|
- Token-bucket rate limiting.
|
||||||
|
- Retries on 429/5xx with exponential backoff, honouring `Retry-After` (seconds or HTTP date) and `X-RateLimit-Reset-In`.
|
||||||
|
- Throws `HttpError { status, body }` after the retries run out.
|
||||||
|
- `json` sends `Accept: application/json` and appends `query`.
|
||||||
|
- `testing.ts`: `createFixtureHttp(routes: Record<string, unknown | ((req: Request) => unknown)>): { fetch: typeof fetch; calls: Request[] }`. The key is `"METHOD url-without-query"` or `"METHOD full-url"`. An unknown route throws. `createTestContext(partial?: Partial<PluginContext> & { fetch?: typeof fetch }): PluginContext & { saved: { credentials: Record<string,string>[]; cursor: string[] } }` provides in-memory credentials and cursor. `collect<T>(it: AsyncIterable<T>): Promise<T[]>`.
|
||||||
|
- `partial-date.ts`: `parsePartialDate(input: string | null | undefined): PartialDate | null` accepts `2019`, `2019-03`, `2019-03-04`, full ISO datetime, `03/04/2019`→null (ambiguous), and `""`→null. `comparePartialDates(a, b): number`.
|
||||||
|
|
||||||
|
- [ ] **Step 1: Failing tests.**
|
||||||
|
- `http.test.ts`:
|
||||||
|
- `"retries 429 honouring Retry-After"`: fetch returns 429 `Retry-After: 2` then 200; `sleep` was called with 2000; the result parsed.
|
||||||
|
- `"gives up after maxRetries with HttpError"`.
|
||||||
|
- `"rate limits to ratePerMinute"`: with a fake clock, 3 calls at `ratePerMinute: 60, burst: 1` sleep ~1000ms between calls.
|
||||||
|
- `partial-date.test.ts`: a table of the inputs above to the expected `{value, precision}`.
|
||||||
|
- [ ] **Step 2:** FAIL.
|
||||||
|
- [ ] **Step 3:** Implement.
|
||||||
|
- [ ] **Step 4:** `bun test packages/plugin-sdk` → PASS.
|
||||||
|
- [ ] **Step 5:** Commit `feat(sdk): plugin contract, http client, fixture harness`.
|
||||||
|
|
||||||
|
### Task 4: Auth: users, sessions, invites, app tokens
|
||||||
|
|
||||||
|
**Files:** Create `packages/core/src/auth.ts`. Test: `packages/core/test/auth.test.ts`.
|
||||||
|
|
||||||
|
**Interfaces (produces):**
|
||||||
|
- `registerUser(db: Db, input: { username: string; password: string; displayName?: string; inviteCode?: string }): Promise<User>`. The first user becomes `admin` and needs no invite. Later users need a valid, unused, unexpired invite or it throws `AuthError("invite_required")`. Usernames match `/^[a-z0-9_]{2,32}$/`, lowercased, unique.
|
||||||
|
- `login(db, username, password): Promise<{ user: User; sessionToken: string }>`. Sessions last 30 days. The token is 32 random bytes in base64url; only its SHA-256 is stored.
|
||||||
|
- `getSessionUser(db, token): Promise<User | null>`, `logout(db, token)`.
|
||||||
|
- `createInvite(db, adminId, ttlDays = 7): Promise<string>` (admins only, else `AuthError("forbidden")`).
|
||||||
|
- `createAppToken(db, userId, name, scopes): Promise<{ id: string; token: string }>`. The token is formatted `stt_<base64url>` and only shown once. `verifyAppToken(db, token, scope): Promise<User | null>` updates `last_used_at`. `revokeAppToken(db, userId, id)`.
|
||||||
|
- `type User = { id: string; username: string; displayName: string; role: "admin" | "member" }`. Passwords use `Bun.password.hash` (argon2id).
|
||||||
|
|
||||||
|
- [ ] **Step 1: Failing tests** (`forEachDialect`):
|
||||||
|
- first user is admin;
|
||||||
|
- second registration without invite → `invite_required`;
|
||||||
|
- with invite → member, and the invite cannot be reused;
|
||||||
|
- wrong password → `AuthError("invalid_credentials")`;
|
||||||
|
- session round-trip and logout;
|
||||||
|
- app token: `verifyAppToken` with a missing scope → null; revoked → null.
|
||||||
|
- [ ] **Steps 2–4:** FAIL, then implement, then PASS on both dialects.
|
||||||
|
- [ ] **Step 5:** Commit `feat(core): users, sessions, invites, app tokens`.
|
||||||
|
|
||||||
|
### Task 5: Catalogue: works, external IDs, matching
|
||||||
|
|
||||||
|
**Files:** Create `packages/core/src/works.ts` and `packages/core/src/matching.ts`. Tests: `packages/core/test/{works,matching}.test.ts`.
|
||||||
|
|
||||||
|
**Interfaces (produces):**
|
||||||
|
- `createWork(db, input: { hint: WorkHint; ownerUserId?: string | null; parentId?: string | null }, actor: string): Promise<string>` inserts the work and its external_ids.
|
||||||
|
- `getWork(db, id): Promise<Work | null>` follows `merged_into_id` chains.
|
||||||
|
- `updateWorkFromHint(db, workId, hint, actor)` fills or updates shared-work display fields, writes `work_history`, adds missing external IDs, and never touches private works.
|
||||||
|
- `findWorkByExternalIds(db, ids: Record<string,string>): Promise<string | null>`.
|
||||||
|
- `searchWorks(db, userId, q: string, opts?: { mediaType?: MediaType; limit?: number }): Promise<Work[]>` covers shared works plus the user's private ones, with a case-insensitive `LIKE` on title.
|
||||||
|
- `matchWork(db, userId, hint: WorkHint, actor: string): Promise<{ workId: string; created: boolean; draft: boolean }>` follows spec §8.4:
|
||||||
|
1. By external ID: link, and update display fields.
|
||||||
|
2. If `hint.parent` + `hint.position`: resolve the parent recursively, then find the child by (parent_id, position). If not found, create a shared child.
|
||||||
|
3. Hint has external IDs: create a shared work.
|
||||||
|
4. Otherwise: create a private draft (`owner_user_id = userId`) and store up to 5 `match_suggestions`, where `fuzzyScore ≥ 0.6`.
|
||||||
|
- Shows, seasons and episodes create the whole chain.
|
||||||
|
- `fuzzyScore(a: {title, year?, creators?}, b: same): number` returns 0..1. Normalise case, punctuation, leading "the/a/an" and diacritics. Score is title token-set Jaccard × 0.7, + year match 0.2 (±1 year counts as 0.1), + any creator match 0.1.
|
||||||
|
- `resolveAlias(db, userId, workId): Promise<string>`.
|
||||||
|
- `acceptSuggestion(db, userId, suggestionId)` moves the user's entry from the draft work to the candidate (via `moveEntryToWork` from T6, imported lazily to avoid a cycle). `rejectSuggestion(db, userId, suggestionId)`.
|
||||||
|
- `adminMergeWorks(db, adminUserId, fromId, toId)` (admin only, else `AuthError("forbidden")`) sets `merged_into_id`, moves the external_ids, writes `work_history`, and repoints every user's entries unless the user already has an entry on `toId`. In that case the user's `from` entry is kept and they get a `pending` `merged` change event.
|
||||||
|
- `type Work` mirrors the row with JSON fields parsed.
|
||||||
|
|
||||||
|
- [ ] **Step 1: Failing tests:**
|
||||||
|
- `"match by isbn13 links existing"`;
|
||||||
|
- `"episode hint creates show→season→episode chain once"` (calling it twice → same ids, 3 works total);
|
||||||
|
- `"hint without ids creates private draft with suggestions"`;
|
||||||
|
- `"private works invisible to other users in search"`;
|
||||||
|
- `fuzzyScore("The Expanse: Leviathan Wakes", "Leviathan Wakes")` > 0.6;
|
||||||
|
- `fuzzyScore("Dune","Dune Messiah")` < 0.6;
|
||||||
|
- `"Pokémon"` vs `"Pokemon"` = 1 when year and creators are equal or absent.
|
||||||
|
- [ ] **Steps 2–4:** FAIL, then implement, then PASS on both dialects.
|
||||||
|
- [ ] **Step 5:** Commit `feat(core): works catalogue and matching`.
|
||||||
|
|
||||||
|
### Task 6: User layer: entries, engagements, history, lists, copies, custom fields
|
||||||
|
|
||||||
|
**Files:** Create `packages/core/src/{history.ts,entries.ts,engagements.ts,lists.ts,copies.ts,custom-fields.ts}`. Tests: one test file per source file in `packages/core/test/`.
|
||||||
|
|
||||||
|
**Interfaces (produces):**
|
||||||
|
- `recordHistory(tx, { userId, entity, entityId, before, after, actor })` stores only the changed keys.
|
||||||
|
- `getHistory(db, userId, entity, entityId)` and `restoreVersion(db, userId, historyId)` (re-applies `before` as actor `user`).
|
||||||
|
- `type Actor = "user" | \`plugin:${string}:${string}\` | \`token:${string}\``. Plugin actors are `plugin:<pluginId>:<connectionId>`.
|
||||||
|
- `upsertEntry(db, userId, workId, patch: Partial<EntryPatch>, actor: Actor): Promise<Entry>`. `EntryPatch` keys: `status rating review privateNotes favorite tags customFields visibility overrides mediaTypeOverride`.
|
||||||
|
- It sets `field_sources[key] = actor` for each patched key.
|
||||||
|
- It writes history.
|
||||||
|
- It validates rating 0–100 and custom fields against definitions.
|
||||||
|
- Defaults: `status "none"`, `visibility` from user prefs (`"private"`).
|
||||||
|
- `getEntry(db, userId, entryId)`.
|
||||||
|
- `listEntries(db, userId, filter: { mediaType?; status?; tag?; draft?: boolean; q?; parentId?: string | null; sort?: "updated" | "title" | "rating"; limit?; cursor? }): Promise<{ items: EntryView[]; nextCursor: string | null }>`.
|
||||||
|
- `EntryView` = entry + work (with overrides applied) + `draft` (the work has no external IDs) + `childSummary: { total: number; completed: number } | null`.
|
||||||
|
- The default `parentId: null` lists top-level only.
|
||||||
|
- `softDeleteEntry`, `moveEntryToWork(db, userId, entryId, targetWorkId, actor)` (writes a `work_aliases` row).
|
||||||
|
- `quickAdd(db, userId, { title, mediaType? = "other" }): Promise<EntryView>` creates a private draft work, then the entry.
|
||||||
|
- `addEngagement` / `updateEngagement` / `softDeleteEngagement` (the same field_sources and history rules), plus `listEngagements(db, userId, entryId)`.
|
||||||
|
- Lists:
|
||||||
|
- `createList`, `updateList`, `addListItem`, `moveListItem`, `removeListItem`, `getList`, `listLists`;
|
||||||
|
- `kind` is `list|grouping|thread`, and a grouping may carry `rating`/`review`.
|
||||||
|
- Copies: `createCopy`, `updateCopy`, `softDeleteCopy`, `listCopies(db, userId, { workId? })`. Price is stored in cents.
|
||||||
|
- Custom fields: `defineCustomField`, `listCustomFields(db, userId, mediaType?)`, `validateCustomValues(defs, values)`, which throws `ValidationError(field, reason)`.
|
||||||
|
|
||||||
|
- [ ] **Step 1: Failing tests:**
|
||||||
|
- `"upsertEntry records field_sources and history"`;
|
||||||
|
- `"restoreVersion brings back previous review"`;
|
||||||
|
- `"rating 101 rejected"`;
|
||||||
|
- `"quickAdd creates draft entry"` (`draft === true`);
|
||||||
|
- `"listEntries rolls up children"`: a show with 3 episode entries, 2 completed → `childSummary {total:3, completed:2}`, and episodes are not listed at the top level;
|
||||||
|
- `"engagement with year precision keeps '2019'"`;
|
||||||
|
- `"moveEntryToWork leaves alias"`;
|
||||||
|
- `"custom select field rejects unknown option"`;
|
||||||
|
- `"grouping list can be rated"`.
|
||||||
|
- [ ] **Steps 2–4:** FAIL, then implement, then PASS on both dialects.
|
||||||
|
- [ ] **Step 5:** Commit `feat(core): entries, engagements, history, lists, copies, custom fields`.
|
||||||
|
|
||||||
|
### Task 7: Attachments and secret encryption
|
||||||
|
|
||||||
|
**Files:** Create `packages/core/src/attachments.ts` and `packages/core/src/crypto.ts`. Tests: `packages/core/test/{attachments,crypto}.test.ts`.
|
||||||
|
|
||||||
|
**Interfaces (produces):**
|
||||||
|
- `encryptJson(secret: string, value: unknown): Promise<string>` and `decryptJson<T>(secret: string, blob: string): Promise<T>`. Use AES-256-GCM via WebCrypto, with the key = HKDF-SHA256 of `secret` and info `"sttracker-credentials"`. The blob is `v1.<b64 iv>.<b64 ciphertext>`. A wrong secret throws `CryptoError`.
|
||||||
|
- `storeAttachment(db, dataDir, userId, file: { bytes: Uint8Array; mime: string; filename: string; caption?: string; takenAt?: string }, link: { entity; entityId }): Promise<Attachment>`. Content is addressed by sha256 at `${dataDir}/attachments/ab/cd/<sha256>` and deduplicated. Allowed mimes are `image/*`, `application/pdf` and `text/plain`, with a max of 25 MB (else `ValidationError`).
|
||||||
|
- `readAttachment(db, dataDir, userId, id): Promise<{ file: BunFile; mime }>` checks the owner.
|
||||||
|
- `listAttachments(db, userId, entity, entityId)`, `unlinkAttachment(...)`.
|
||||||
|
- Thumbnails are out of Phase 1. The UI sizes images with CSS (recorded in TODO).
|
||||||
|
|
||||||
|
- [ ] **Step 1: Failing tests:** encrypt/decrypt round-trip; wrong secret throws; the same bytes stored twice produce one file and two links; `.exe` mime rejected; another user's read → null.
|
||||||
|
- [ ] **Steps 2–4:** FAIL, then implement, then PASS.
|
||||||
|
- [ ] **Step 5:** Commit `feat(core): attachments and credential encryption`.
|
||||||
|
|
||||||
|
### Task 8: Sync engine: snapshots, diff, apply, Changes inbox
|
||||||
|
|
||||||
|
**Files:** Create `packages/core/src/sync/{snapshot.ts,apply.ts,run.ts,changes.ts,listens.ts}`. Tests: `packages/core/test/sync/*.test.ts`.
|
||||||
|
|
||||||
|
**Interfaces:**
|
||||||
|
- **Consumes:** `SourcePlugin`, `SourceRecord`, `PluginContext` (T3); `matchWork`, `updateWorkFromHint` (T5); `upsertEntry`, `addEngagement`, `moveEntryToWork`, `softDeleteEntry`, `softDeleteEngagement` (T6).
|
||||||
|
- **Produces:**
|
||||||
|
- `runSync(deps: { db: Db; makeContext: (conn: Connection, runId: string) => PluginContext }, plugin: SourcePlugin, connectionId: string, mode: "full" | "changes" | { file: { name: string; bytes: Uint8Array } }): Promise<RunSummary>`.
|
||||||
|
- `RunSummary = { runId; status: "ok" | "failed"; counts: { seen; added; changed; removed; merged; conflicts; autoApplied; pending; listens }; error?: string }`.
|
||||||
|
- `listChanges(db, userId, { status?: "pending" | "all"; connectionId?; limit?; cursor? })`.
|
||||||
|
- `resolveChange(db, userId, changeId, action: "accept" | "ignore" | "keep_mine"): Promise<void>`.
|
||||||
|
- `ingestListens(db, userId, source: string, records: SourceRecord[]): Promise<{ inserted: number }>`.
|
||||||
|
|
||||||
|
**Rules (spec §8.3; implement exactly):**
|
||||||
|
1. **Hash** = sha256 of a stable-key-ordered JSON of `{kind, work, entry|engagement|listen|list, deleted, mergedIntoKey}`. The raw payload is excluded from the hash, but stored in a version gzipped with `Bun.gzipSync` and base64-encoded. A new `source_record_versions` row is written only when the hash differs from `current_hash`.
|
||||||
|
2. **New record (`added`):** match the work. For `entry`, `upsertEntry` with only the fields present, under the plugin actor. For `engagement`, ensure an entry exists, then de-duplicate: if any non-deleted engagement on that entry has `finished_at` within 24 h of the incoming one (day-precision values compare as dates) or an equal `started_at` datetime, attach an `engagement_sources` row instead of inserting. Write a `change_events` row with status `auto_applied`.
|
||||||
|
3. **Changed record:** for each field that differs from the previous normalized version:
|
||||||
|
- `field_sources[f]` is unset or equals this connection's actor → apply, `auto_applied`;
|
||||||
|
- it is `"user"` → `pending` `changed` event with `{field, from, to}`, and the current value is kept;
|
||||||
|
- it is another connection's actor and the value differs → `pending` `conflict` event with `{field, current, currentSource, incoming, incomingSource}`.
|
||||||
|
- A pending event for the same (record, field, incoming value) is never duplicated. That is the anti-flip-flop rule.
|
||||||
|
4. **Removed:** only when the mode is `"full"`, the plugin iteration completed without throwing, and the record was not seen in this run, or when `deleted: true` arrives in any mode. Set `source_records.deleted_at` and write a `pending` `removed` event. Accepting it soft-deletes the target entry or engagement.
|
||||||
|
5. **Merged:** when `mergedIntoKey` is set, write a `pending` `merged` event. Accepting it calls `moveEntryToWork` to the work matched for the target record's hint.
|
||||||
|
6. **Listens:** go to `ingestListens`. Insert-or-ignore on (user, source, source_key), and match a work only via `mbids.recording` (→ an existing work, or a shared track work created with a `musicbrainz_recording` id). There is no change event per listen; counts go in the run summary.
|
||||||
|
7. **`resolveChange`:** `accept` applies the incoming value with actor `plugin:…` and sets `field_sources` to that actor. `keep_mine` sets `field_sources[f] = "user"` for conflicts. `ignore` does nothing to data. All three set `status` and `resolved_at`.
|
||||||
|
8. **Run bookkeeping:** set `source_runs.status`, `counts`, `log` and `error`; `source_connections.last_run_at`; and set `status = "error"` on failure.
|
||||||
|
|
||||||
|
- [ ] **Step 1: Failing tests** using a fake in-test plugin whose `pull` yields records from an array:
|
||||||
|
- `"first full pull creates entries and auto_applied events"`;
|
||||||
|
- `"second identical pull creates nothing new"` (Review focus 2: counts versions, events, engagements and listens before/after);
|
||||||
|
- `"failed pull marks nothing removed"` (Review focus 1: the plugin yields 1 of 2 records, then throws);
|
||||||
|
- `"record missing from completed full pull → pending removed; accept soft-deletes entry"`;
|
||||||
|
- `"user-edited rating not overwritten; pending changed event"` (Review focus 3);
|
||||||
|
- `"two connections disagree → one pending conflict, not duplicated on rerun; keep_mine stops further conflicts for that value"`;
|
||||||
|
- `"same play from two sources dedupes into one engagement with two engagement_sources"`;
|
||||||
|
- `"merged record → pending merged; accept moves entry"`;
|
||||||
|
- `"listens insert-or-ignore and mbid matching"`.
|
||||||
|
- All of these run on both dialects.
|
||||||
|
- [ ] **Steps 2–4:** FAIL, then implement, then PASS on both dialects.
|
||||||
|
- [ ] **Step 5:** Commit `feat(core): sync engine and changes inbox`.
|
||||||
|
|
||||||
|
### Task 9: Jobs, scheduler, plugin registry, connections
|
||||||
|
|
||||||
|
**Files:** Create `packages/core/src/{jobs.ts,registry.ts,connections.ts}`. Tests: `packages/core/test/{jobs,registry,connections}.test.ts`, plus fixture plugin `packages/core/test/fixtures/plugins/echo/index.ts`.
|
||||||
|
|
||||||
|
**Interfaces (produces):**
|
||||||
|
- `enqueue(db, type: string, payload: unknown, opts?: { runAt?: string; lockKey?: string }): Promise<string | null>` returns null when a `queued|running` job with the same `lockKey` exists. Sync jobs use `lockKey = "sync:<connectionId>"` (Review focus 5).
|
||||||
|
- `startWorker(db, handlers: Record<string, (payload: any) => Promise<void>>, opts?: { pollMs?: number; concurrency?: number }): { stop(): Promise<void> }`.
|
||||||
|
- A job is claimed atomically (`update … where id = ? and status = 'queued'` and check affected rows).
|
||||||
|
- Retry with backoff `2^attempts` minutes up to 5 attempts, then `failed`.
|
||||||
|
- `startScheduler(db, opts?: { tickMs?: number }): { stop() }` enqueues `sync` for each active connection whose `last_run_at + schedule_minutes` has passed.
|
||||||
|
- `loadPlugins(dirs: string[]): Promise<PluginRegistry>`, where `PluginRegistry = { get(id): SourcePlugin | undefined; list(): PluginManifest[]; reload(): Promise<void> }`.
|
||||||
|
- Each subdirectory with `index.ts` whose default export passes `definePlugin` shape checks is registered (`sdkVersion === 1`, unique id).
|
||||||
|
- `reload` re-imports with `?v=<Date.now()>`.
|
||||||
|
- A bad plugin is logged and skipped, never fatal.
|
||||||
|
- Connections:
|
||||||
|
- `createConnection(db, secret, userId, { pluginId, name, config, credentials?, scheduleMinutes? })`, `updateConnection`, `deleteConnection` (soft: `status "paused"` + credentials cleared), `listConnections(db, userId)`;
|
||||||
|
- `makePluginContext(db, secret, conn, runId, http: HttpClient): PluginContext`. Its credentials `save` encrypts and persists *immediately* (Review focus 5), and its cursor is persisted on `set`.
|
||||||
|
- `syncHandler(deps)` is the `"sync"` job handler: it loads the connection and plugin, calls `runSync` with `mode` from the payload (default `pullChanges` if the plugin supports it, otherwise `full`), and runs a weekly `full` regardless.
|
||||||
|
|
||||||
|
- [ ] **Step 1: Failing tests:**
|
||||||
|
- `"enqueue with same lockKey returns null"`;
|
||||||
|
- `"worker retries then fails"`;
|
||||||
|
- `"scheduler enqueues due connections only"`;
|
||||||
|
- `"registry loads echo plugin and skips broken one"`;
|
||||||
|
- `"credentials.save persists encrypted before pull continues"` (the echo plugin saves creds, then throws; the DB holds the new encrypted creds).
|
||||||
|
- [ ] **Steps 2–4:** FAIL, then implement, then PASS on both dialects.
|
||||||
|
- [ ] **Step 5:** Commit `feat(core): jobs, scheduler, plugin registry, connections`.
|
||||||
|
|
||||||
|
### Task 10: Trakt plugin
|
||||||
|
|
||||||
|
**Files:** `plugins/trakt/{package.json,index.ts,src/*.ts,test/trakt.test.ts,test/fixtures/*.json}`.
|
||||||
|
|
||||||
|
**Interfaces:** `definePlugin` with:
|
||||||
|
- manifest `id "trakt"`, capabilities `["pull","pullChanges","lookup"]`, mediaTypes `movie show season episode`;
|
||||||
|
- config: `clientId` (string, required), `clientSecret` (secret, required);
|
||||||
|
- `attribution` = the exact Trakt line from Global constraints;
|
||||||
|
- `deviceAuth` via `POST /oauth/device/code` and `/oauth/device/token`.
|
||||||
|
|
||||||
|
Requests use base `https://api.trakt.tv` with headers `trakt-api-version: 2` and `trakt-api-key: clientId`, at `ratePerMinute: 100`. Before each run, refresh the access token if it expires within 24 h (`POST /oauth/token`, `grant_type=refresh_token`), then **`ctx.credentials.save` before any other request**.
|
||||||
|
|
||||||
|
**pull** yields:
|
||||||
|
- `GET /sync/history?page=&limit=250` (paged by `X-Pagination-Page-Count`): each history item → an `engagement` record with key `history:<id>`, and `finishedAt` = `watched_at` at day precision but keeping the full datetime in `value`. Movies: hint `movie` with ids `{trakt, tmdb_movie, imdb}`. Episodes: hint `episode` with `parent` season → `parent` show (`tmdb_show`, `tvdb`, `imdb`, `trakt`) and `position {season, episode}`.
|
||||||
|
- `GET /sync/ratings`: `entry` records with key `rating:<type>:<traktId>`, `rating = rating × 10`.
|
||||||
|
- `GET /sync/watchlist`: `entry` with status `planned`, key `watchlist:<type>:<traktId>`.
|
||||||
|
- `GET /users/me/lists` + `/users/me/lists/:id/items`: `list` records.
|
||||||
|
|
||||||
|
**pullChanges:** `GET /sync/last_activities`, compared with the cursor (JSON of the last activity timestamps). For the categories that changed, re-fetch:
|
||||||
|
- history with `start_at = previous cursor time`;
|
||||||
|
- ratings and watchlist in full.
|
||||||
|
- Set the new cursor at the end.
|
||||||
|
|
||||||
|
**lookup:** `GET /search/movie,show?query=`.
|
||||||
|
|
||||||
|
- [ ] **Step 1: Failing tests with fixtures:** history paging over 2 pages; episode hint chain and position; rating 8 → 80; watchlist → planned; a token near expiry is refreshed and saved before the first `/sync` call (assert the order of `fixture.calls` and `ctx.saved.credentials`); `pullChanges` with an unchanged `last_activities` yields nothing.
|
||||||
|
- [ ] **Steps 2–4:** FAIL, then implement, then PASS (`bun test plugins/trakt`).
|
||||||
|
- [ ] **Step 5:** Commit `feat(plugins): trakt`.
|
||||||
|
|
||||||
|
### Task 11: Hardcover plugin
|
||||||
|
|
||||||
|
**Files:** `plugins/hardcover/...` (same layout as Task 10).
|
||||||
|
|
||||||
|
**Interfaces:** manifest `id "hardcover"`, capabilities `["pull","pullChanges","lookup"]`, mediaTypes `book`, config `token` (secret, required: personal access token, sent as `Authorization: Bearer`), and attribution "Book data from Hardcover." Requests are `POST https://api.hardcover.app/v1/graphql` at `ratePerMinute: 50`. GraphQL errors throw `HttpError`.
|
||||||
|
|
||||||
|
**pull** pages `me { user_books(limit: 100, offset) { id status_id rating review_raw private_notes starred updated_at merged_at original_book_id book { id title release_year contributions { author { name } } book_series { position series { id name } } } edition { id isbn_13 isbn_10 asin } user_book_reads { id started_at finished_at progress_pages } } }` and yields:
|
||||||
|
- `entry` with key `ub:<id>`:
|
||||||
|
- status map: 1→planned, 2→in_progress, 3→completed, 4→paused, 5→dropped, 6→none;
|
||||||
|
- `rating` ×20 (Hardcover is 0–5 with halves);
|
||||||
|
- `review_raw` → review, `private_notes` → notes, starred → favorite;
|
||||||
|
- hint ids `{hardcover_book, isbn13, isbn10, asin, hardcover_edition}`; if there is a series, `parent` = series hint (`hardcover_series`) with `position {index}`.
|
||||||
|
- `engagement` for each read with key `read:<id>`, day-precision dates.
|
||||||
|
- When `merged_at` is set and `original_book_id ≠ book.id`, also set `mergedIntoKey` on a synthetic record with key `ub:<id>@book:<original_book_id>`, so the engine sees the old identity merged into the new one.
|
||||||
|
- Lists via `me { lists { id name description list_books { book {…} } } }`.
|
||||||
|
|
||||||
|
**pullChanges:** the same query filtered by `updated_at > cursor`. Deletions are not reported, so the weekly full pull detects them.
|
||||||
|
|
||||||
|
- [ ] **Step 1: Failing tests:** status and rating mapping (4.5 → 90); series parent hint; re-reads → 2 engagements; merge produces `mergedIntoKey`; GraphQL `errors` → throws; offset paging stops on a short page.
|
||||||
|
- [ ] **Steps 2–4:** FAIL, then implement, then PASS.
|
||||||
|
- [ ] **Step 5:** Commit `feat(plugins): hardcover`.
|
||||||
|
|
||||||
|
### Task 12: MangaBaka plugin
|
||||||
|
|
||||||
|
**Files:** `plugins/mangabaka/...`.
|
||||||
|
|
||||||
|
**Interfaces:** manifest `id "mangabaka"`, capabilities `["pull","pullChanges","lookup"]`, mediaTypes `manga comic`, config `token` (secret, required, header `x-api-key`), and attribution "Manga data from MangaBaka (CC BY-NC-SA 4.0)." Base `https://api.mangabaka.org`, `ratePerMinute: 150`.
|
||||||
|
|
||||||
|
**pull** pages `GET /v1/my/library?page=` and yields `entry` records keyed `series:<series_id>`:
|
||||||
|
- state map: considering|plan_to_read→planned, reading|rereading→in_progress, paused→paused, dropped→dropped, completed→completed;
|
||||||
|
- rating is already 0–100;
|
||||||
|
- `note` → notes;
|
||||||
|
- progress → an `engagement` record keyed `progress:<series_id>` with `progress {chapter, volume}` and start/finish dates;
|
||||||
|
- hint ids `{mangabaka, anilist, mal, mangaupdates, kitsu}` from `source.*`.
|
||||||
|
- If the series `state === "merged"`, set `mergedIntoKey = "series:<merged_with>"`.
|
||||||
|
|
||||||
|
**pullChanges:** `GET /v2/my/library/changes?cursor=`. Upserts yield records, deletes yield `deleted: true`, and the new cursor is saved.
|
||||||
|
|
||||||
|
- [ ] **Step 1: Failing tests:** state mapping table; a merged series sets `mergedIntoKey`; the changes feed yields a deletion; cursor saved.
|
||||||
|
- [ ] **Steps 2–4:** FAIL, then implement, then PASS.
|
||||||
|
- [ ] **Step 5:** Commit `feat(plugins): mangabaka`.
|
||||||
|
|
||||||
|
### Task 13: CSV/JSON import plugin and CSV codec
|
||||||
|
|
||||||
|
**Files:** `packages/plugin-sdk/src/csv.ts` (the codec lives in the SDK so core can reuse it), `plugins/csv/{index.ts,src/mapping.ts,test/*.test.ts,test/fixtures/*}`.
|
||||||
|
|
||||||
|
**Interfaces (produces):**
|
||||||
|
- `parseCsv(text: string, opts?: { delimiter?: "," | ";" | "\t" }): string[][]` (RFC 4180). It strips a BOM and auto-detects the delimiter from the header line.
|
||||||
|
- `toCsv(rows: string[][]): string` quotes as needed and uses `\r\n`. This is reused by Task 15.
|
||||||
|
- The manifest is `id "csv"`, capabilities `["importFile"]`, and config `mapping` (string: JSON `{ column: field }`, where the fields are `title mediaType status rating ratingScale(5|10|100) review finishedAt startedAt tags isbn13 imdb tmdb_movie igdb notes`).
|
||||||
|
- `importFile`:
|
||||||
|
- **`.csv`:** maps rows into `entry` records (plus an `engagement` when dates are present), keyed `row:<sha1 of title+mediaType+finishedAt>`.
|
||||||
|
- **`.json`:** accepts an array of objects with the same field names.
|
||||||
|
- **Without a mapping**, it guesses from header names, case-insensitively, including the Goodreads export columns (`Title`, `Author`, `My Rating`, `Date Read`, `ISBN13`, `Exclusive Shelf`, `My Review`).
|
||||||
|
|
||||||
|
- [ ] **Step 1: Failing tests (Review focus 4):** a fixture `messy.csv` with BOM, `;`, quoted newline and `""` quotes, an empty rating, `Date Read` = `2019`, and an emoji title; `parseCsv(toCsv(x))` deep-equals `x`; Goodreads fixture headers are auto-mapped; `Exclusive Shelf` `to-read` → planned and `read` → completed; `My Rating` 4 at scale 5 → 80.
|
||||||
|
- [ ] **Steps 2–4:** FAIL, then implement, then PASS.
|
||||||
|
- [ ] **Step 5:** Commit `feat(plugins): csv and json import`.
|
||||||
|
|
||||||
|
### Task 14: ListenBrainz and Last.fm plugins
|
||||||
|
|
||||||
|
**Files:** `plugins/listenbrainz/...` and `plugins/lastfm/...`.
|
||||||
|
|
||||||
|
**ListenBrainz:** manifest `id "listenbrainz"`, capabilities `["pull","pullChanges"]`, mediaTypes `track album artist`, config `username` (string) + `token` (secret, `Authorization: Token`). Base `https://api.listenbrainz.org`. Respect `X-RateLimit-Reset-In` (already handled in T3).
|
||||||
|
- **pull (full backfill):**
|
||||||
|
1. `POST /1/export/` (if the response is 400 "already pending", fall through to listing).
|
||||||
|
2. Poll `GET /1/export/list` every 30 s via `ctx.now`/an injected sleep until the newest export's status is `complete`.
|
||||||
|
3. Download `GET /1/export/{id}/download`.
|
||||||
|
4. Unzip with `fflate.unzipSync`.
|
||||||
|
5. Stream `listens/**/*.jsonl` → `listen` records keyed `<listened_at>:<recording_msid>`, with `mbids` from `track_metadata.mbid_mapping`.
|
||||||
|
6. `feedback.jsonl` with score 1 → an `entry` with `favorite: true`, track hint ids `{musicbrainz_recording}`.
|
||||||
|
- **pullChanges:** `GET /1/user/{u}/listens?min_ts=<cursor>&count=1000` loops until empty, and the cursor becomes the max `listened_at`.
|
||||||
|
|
||||||
|
**Last.fm:** manifest `id "lastfm"`, capabilities `["pull","pullChanges"]`, config `username` + `apiKey` (secret). Base `https://ws.audioscrobbler.com/2.0/`, `format=json`, `ratePerMinute: 240`.
|
||||||
|
- **pull:** pages `user.getRecentTracks&limit=200&page=`, skipping the `@attr.nowplaying` track, yielding `listen` records keyed `<uts>:<artist>:<track>` with MBIDs when non-empty. Also `user.getLovedTracks` → favorite `entry` records.
|
||||||
|
- **pullChanges:** `from=<cursor>`.
|
||||||
|
- Error code 29 → throw `HttpError` with status 429 so the client retries.
|
||||||
|
|
||||||
|
- [ ] **Step 1: Failing tests:**
|
||||||
|
- LB: export flow over fixtures (a pending then complete list, plus a zip built in-test with `fflate.zipSync`); a feedback love → favorite entry; the incremental cursor advances.
|
||||||
|
- Last.fm: the nowplaying track is skipped; empty MBIDs are omitted; error 29 is retried (via the fixture fetch returning 29 once).
|
||||||
|
- [ ] **Steps 2–4:** FAIL, then implement, then PASS.
|
||||||
|
- [ ] **Step 5:** Commit `feat(plugins): listenbrainz and lastfm`.
|
||||||
|
|
||||||
|
### Task 15: Export bundle, restore, scheduled backups
|
||||||
|
|
||||||
|
**Files:** Create `packages/core/src/export.ts` and `packages/core/src/backup.ts`. Tests: `packages/core/test/{export,backup}.test.ts`.
|
||||||
|
|
||||||
|
**Interfaces (produces):**
|
||||||
|
- `exportUserBundle(db, dataDir, userId): Promise<Uint8Array>` returns a zip (fflate) containing:
|
||||||
|
- `manifest.json` `{ format: "sttracker-export/v1", exportedAt, user: { username, displayName } }`;
|
||||||
|
- `works.jsonl` (every referenced work, plus ancestors, with `externalIds`);
|
||||||
|
- `entries.jsonl`, `engagements.jsonl`, `listens.jsonl`, `lists.jsonl` (with items), `copies.jsonl`, `custom_fields.jsonl`, `history.jsonl`, `attachments.jsonl`;
|
||||||
|
- `attachments/<sha256>` file bytes.
|
||||||
|
- It excludes credentials, sessions and tokens.
|
||||||
|
- In all files, camelCase keys mirror the `Entry`/`Work` view types.
|
||||||
|
- `exportUserCsv(db, userId): Promise<Record<string, string>>` returns `{ "entries.csv": …, "engagements.csv": …, "listens.csv": … }` using `toCsv` from `@sttracker/plugin-sdk/csv` (Task 13).
|
||||||
|
- `importUserBundle(db, dataDir, userId, zip: Uint8Array): Promise<{ works: number; entries: number }>` refuses unless the user has no entries (`ImportError("account_not_empty")`). It re-matches works by external IDs; unmatched works become private works; old IDs are remapped to new ones.
|
||||||
|
- `runBackup(db, dataDir, opts: { keep: number }): Promise<string>` writes `${dataDir}/backups/<ISO>/` containing one bundle per user, plus on SQLite `VACUUM INTO 'sttracker.db'`. It prunes to the newest `keep` (default 14).
|
||||||
|
- `"backup"` job handler: `backupHandler(deps)`.
|
||||||
|
|
||||||
|
- [ ] **Step 1: Failing tests:**
|
||||||
|
- `"export then import into fresh user reproduces entries, ratings, engagements with precision, lists and attachments"`;
|
||||||
|
- `"bundle has no credential fields"` (grep the zip text for `password`, `token`, `credentials`);
|
||||||
|
- `"import into non-empty account refused"`;
|
||||||
|
- `"backup prunes to keep"`.
|
||||||
|
- All on both dialects (VACUUM is SQLite only).
|
||||||
|
- [ ] **Steps 2–4:** FAIL, then implement, then PASS.
|
||||||
|
- [ ] **Step 5:** Commit `feat(core): export bundle, restore, backups`.
|
||||||
|
|
||||||
|
### Task 16: `apps/server`: API, auth middleware, ingest mount, OpenAPI, boot
|
||||||
|
|
||||||
|
**Files:** Create `apps/server/src/{main.ts,config.ts,http.ts,routes/*.ts,openapi.ts}` and `apps/server/test/api.test.ts`.
|
||||||
|
|
||||||
|
**Interfaces:**
|
||||||
|
- **Config** (`config.ts`) reads env:
|
||||||
|
- `DATABASE_URL` (default `sqlite://${DATA_DIR}/sttracker.db`);
|
||||||
|
- `DATA_DIR` (default `./data`);
|
||||||
|
- `STTRACKER_SECRET` (required; refuse to boot without it, with the message "Set STTRACKER_SECRET to a long random string. It encrypts source credentials.");
|
||||||
|
- `PUBLIC_URL`, `PORT` (default 3000);
|
||||||
|
- `PLUGIN_DIRS` (default `plugins,${DATA_DIR}/plugins`).
|
||||||
|
- `createApp(deps: { db; registry; secret; dataDir }): { fetch(req: Request): Promise<Response>; routes }` is testable without listening. `main.ts` migrates, loads plugins, starts the worker and scheduler, schedules a daily `backup` job, and calls `Bun.serve({ routes: { "/*": index.html }, fetch: app.fetch })`.
|
||||||
|
- `http.ts`: `route({ method, path, auth: "none" | "session" | "token:read" | "token:write" | "any:read" | "any:write", body?: ZodType, query?: ZodType, handler })`.
|
||||||
|
- JSON errors have the shape `{ error: { code, message } }`, with codes `400 validation`, `401 unauthenticated`, `403 forbidden`, `404 not_found`, `409 conflict`.
|
||||||
|
- Session cookie: `stt_session`, HttpOnly, SameSite=Lax, Secure when `PUBLIC_URL` is https.
|
||||||
|
- `any:*` accepts a session or a Bearer app token.
|
||||||
|
- **Routes under `/api/v1`** (bodies validated with zod; each calls the core function named in Tasks 4–15):
|
||||||
|
- auth: `POST auth/register`, `POST auth/login`, `POST auth/logout`, `GET me`, `GET/POST/DELETE me/tokens`, `POST admin/invites`;
|
||||||
|
- entries: `GET entries` (the `listEntries` filter as query), `POST entries/quick`, `GET/PATCH/DELETE entries/:id`, `GET entries/:id/history`, `POST history/:id/restore`, `POST entries/:id/move`;
|
||||||
|
- engagements: `GET/POST entries/:id/engagements`, `PATCH/DELETE engagements/:id`;
|
||||||
|
- works: `GET works/search`, `GET works/:id`, `GET works/:id/suggestions`, `POST suggestions/:id/accept|reject`;
|
||||||
|
- lists: `GET/POST lists`, `GET/PATCH/DELETE lists/:id`, `POST lists/:id/items`, `PATCH/DELETE list-items/:id`;
|
||||||
|
- copies: `GET/POST copies`, `PATCH/DELETE copies/:id`;
|
||||||
|
- custom fields: `GET/POST custom-fields`;
|
||||||
|
- attachments: `POST attachments` (multipart: `file`, `entity`, `entityId`, `caption`), `GET attachments/:id`, `DELETE attachments/:id/links/:linkId`;
|
||||||
|
- plugins and sources: `GET plugins` (manifests), `GET/POST sources`, `PATCH/DELETE sources/:id`, `POST sources/:id/run` (enqueue, 409 if locked), `POST sources/:id/import` (multipart file → enqueue a file-mode run), `POST sources/:id/device/start`, `POST sources/:id/device/poll`, `GET sources/:id/runs`;
|
||||||
|
- changes: `GET changes`, `POST changes/:id/resolve` `{action}`;
|
||||||
|
- export: `GET export/bundle` (zip download), `GET export/csv?file=entries.csv`, `POST import/bundle`;
|
||||||
|
- `GET openapi.json` (generated from the route table with `z.toJSONSchema`);
|
||||||
|
- `GET about` (attributions from plugin manifests).
|
||||||
|
- `/ingest/<pluginId>/*` → 404 in Phase 1 (the mount point exists, and no plugin has `ingest`).
|
||||||
|
- `POST admin/plugins/reload` (admin) → `registry.reload()`.
|
||||||
|
|
||||||
|
- [ ] **Step 1: Failing tests** (`forEachDialect`, calling `app.fetch` directly):
|
||||||
|
- register → login cookie → `GET me`;
|
||||||
|
- unauthenticated `GET entries` → 401 JSON;
|
||||||
|
- quick add, then list shows a draft;
|
||||||
|
- PATCH rating 150 → 400 `validation`;
|
||||||
|
- an app token with `read` cannot PATCH (403);
|
||||||
|
- another user's entry → 404;
|
||||||
|
- `POST sources` with the echo plugin, then `POST sources/:id/run` twice → the second returns 409;
|
||||||
|
- export bundle content-type is `application/zip`;
|
||||||
|
- `openapi.json` lists `/api/v1/entries`;
|
||||||
|
- booting without `STTRACKER_SECRET` throws the exact message.
|
||||||
|
- [ ] **Steps 2–4:** FAIL, then implement, then PASS.
|
||||||
|
- [ ] Update `AGENTS.md` Commands with Dev (`bun --hot apps/server/src/main.ts`), verified by booting and hitting `/api/v1/openapi.json`. Update `docs/PERMISSIONS.md` and the tool files accordingly.
|
||||||
|
- [ ] **Step 5:** Commit `feat(server): REST API, auth, sources, changes, export`.
|
||||||
|
|
||||||
|
### Task 17: Web app: shell, auth, library, quick add, entry detail
|
||||||
|
|
||||||
|
**Files:** Create `apps/web/{index.html,src/main.tsx,src/api.ts,src/theme.css,src/routes/*.tsx,src/components/*.tsx}` and `apps/web/test/*.test.tsx` (logic only: `api.ts` and formatters).
|
||||||
|
|
||||||
|
Follow `DESIGN.md` tokens exactly and use impeccable `craft`. Routes (wouter):
|
||||||
|
- `/login`, `/register` (the first-run screen when no users exist: "Create the admin account");
|
||||||
|
- `/` (library);
|
||||||
|
- `/entries/:id`.
|
||||||
|
|
||||||
|
Library:
|
||||||
|
- The three-pane layout from the round 2 mockup: sidebar nav (Library, Log, Lists, Owned, Changes with a pending-count badge in `--warn`, a Sources list, Settings), the list pane, and the detail pane.
|
||||||
|
- Search, filter chips by media type and status, and a "Draft" filter.
|
||||||
|
- The Quick add box, with the placeholder text "Quick add: type a title, sort it out later". Enter creates the entry and selects it.
|
||||||
|
- Rows show cover (or the striped draft cover), title, `media type · meta`, and stars or the amber `Draft` badge. Children roll up as "season 3 of 5"-style meta from `childSummary`.
|
||||||
|
|
||||||
|
Detail:
|
||||||
|
- Cover, title, editable rating (stars in the user's scale; the setting defaults to 5 with halves), status select, tags, and custom fields.
|
||||||
|
- The orange change alert when pending changes target this entry, with "See what changed" → `/changes?entry=:id`.
|
||||||
|
- Boxes for Reads/Plays (engagements with partial dates rendered by precision: "Mar 2025 (no exact date)"), Your copy, Attachments (upload, thumbnails via CSS), Linked sources, and Review (Gelasio, Markdown textarea).
|
||||||
|
- Children with their own ratings, a History drawer with restore, and "Link to catalogue" for drafts (suggestions plus search).
|
||||||
|
|
||||||
|
Formatters: `formatRating(value0to100, scale)` and `formatPartialDate(pd)`.
|
||||||
|
|
||||||
|
- [ ] **Step 1: Failing tests:** `formatRating(90, "5half")` = `"★★★★½"`, `formatRating(null, …)` = `""`, `formatPartialDate({value:"2025-03",precision:"month"})` = `"Mar 2025"`, `{value:"2019",precision:"year"}` = `"2019"`; `api.ts` throws an `ApiError` carrying `code` on a non-2xx JSON error.
|
||||||
|
- [ ] **Steps 2–4:** FAIL, then implement, then PASS.
|
||||||
|
- [ ] **Step 5:** Manual check: run the server, register, quick add, and open the detail pane. Take a screenshot and compare it with the mockup. Fix glaring deviations.
|
||||||
|
- [ ] **Step 6:** Commit `feat(web): app shell, library, entry detail`.
|
||||||
|
|
||||||
|
### Task 18: Web app: Changes, Sources, Log, Lists, Owned, Settings, Export, About
|
||||||
|
|
||||||
|
**Files:** `apps/web/src/routes/{changes,sources,log,lists,owned,settings,about}.tsx` plus components. This task depends on Task 17's `api.ts`, `theme.css` and layout components; create new components only.
|
||||||
|
|
||||||
|
- **Changes:**
|
||||||
|
- Grouped by run.
|
||||||
|
- Each card shows `type`, the entry title, a field-level before/after (or both sources' values for conflicts), and buttons "Use the new value" (accept), "Keep mine" (keep_mine) and "Ignore".
|
||||||
|
- Removed events say "<Source> no longer has this. Your copy here is safe." with buttons "Delete here too" (accept) and "Keep it" (ignore).
|
||||||
|
- Merged events say "<Source> merged this into <target title>." with "Follow the merge".
|
||||||
|
- **Sources:**
|
||||||
|
- Plugin cards from `GET plugins`, and a connect form rendered from the `config` fields (secrets as password inputs).
|
||||||
|
- The device-code flow modal shows the `userCode` and the verification link, and polls.
|
||||||
|
- Per connection: status, last run, "Sync now", schedule select (off/hourly/6h/daily), runs log, and file upload for `importFile` plugins.
|
||||||
|
- **Log:** a chronological feed of engagements and listens (paged).
|
||||||
|
- **Lists:** create lists of kind list, grouping or thread; add works via search; drag-to-reorder (up/down buttons are acceptable); a grouping can be rated.
|
||||||
|
- **Owned:** a copies table with add/edit (bought where, when, price, format, notes).
|
||||||
|
- **Settings:** profile, rating scale, default visibility, custom fields editor, app tokens (create shows the token once with "Copy"; revoke), invites (admin), "Reload plugins" (admin), and Export (download bundle and CSVs, import a bundle into an empty account).
|
||||||
|
- **About:** the attributions.
|
||||||
|
|
||||||
|
- [ ] **Step 1: Failing tests** for pure helpers: `describeChange(event)` returns the exact strings above for the removed/merged/conflict fixtures; `scheduleLabel(minutes)`.
|
||||||
|
- [ ] **Steps 2–4:** FAIL, then implement, then PASS.
|
||||||
|
- [ ] **Step 5:** Manual check of the Changes flow against a fake connection (echo plugin) in the running app.
|
||||||
|
- [ ] **Step 6:** Commit `feat(web): changes inbox, sources, log, lists, owned, settings`.
|
||||||
|
|
||||||
|
### Task 19: Docker, compose, CI, docs
|
||||||
|
|
||||||
|
**Files:** Create `Dockerfile`, `docker-compose.yml`, `.dockerignore`, `.env.example` and `.gitea/workflows/ci.yml`. Modify `AGENTS.md` (Commands), `docs/ARCHITECTURE.md` (replace "planned" with reality), `docs/PERMISSIONS.md` and the tool files, `docs/TODO.md` and `README.md` (Quick start).
|
||||||
|
|
||||||
|
- **Dockerfile:** `oven/bun:1.3` base; `bun install --frozen-lockfile --production`; copy the source; `VOLUME /data`; `ENV DATA_DIR=/data`; `EXPOSE 3000`; `CMD ["bun","apps/server/src/main.ts"]`; run as a non-root `bun` user.
|
||||||
|
- **Compose:** the `sttracker` service with `./data:/data`, env from `.env`; plus a `postgres:17-alpine` service under `profiles: ["postgres"]`, with a comment showing the `DATABASE_URL` for it.
|
||||||
|
- **CI:** on push/PR, a postgres:17-alpine service, then `bun install --frozen-lockfile`, `bun run typecheck`, `bun test` with `TEST_POSTGRES_URL` set, and `test $(wc -l < AGENTS.md) -le 150`.
|
||||||
|
|
||||||
|
- [ ] **Step 1:** `docker build -t sttracker:dev .` → succeeds.
|
||||||
|
- [ ] **Step 2:** `docker run --rm -e STTRACKER_SECRET=test -p 3999:3000 sttracker:dev` → `curl -s localhost:3999/api/v1/openapi.json` returns JSON.
|
||||||
|
- [ ] **Step 3:** Repeat with the compose postgres profile and `DATABASE_URL` → boots and migrates.
|
||||||
|
- [ ] **Step 4:** Update the docs. Every command in `AGENTS.md` must be verified in this session (typecheck now passes; `bun test` passes on both DBs).
|
||||||
|
- [ ] **Step 5:** Commit `chore: docker, compose, CI, docs`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Completion
|
||||||
|
|
||||||
|
- [ ] `bun run typecheck` clean. `bun test` passes with and without `TEST_POSTGRES_URL`.
|
||||||
|
- [ ] Whole-branch review (superpowers:requesting-code-review). Fix the findings.
|
||||||
|
- [ ] `git rm docs/plans/001-phase1-own-your-data.md`. Set the spec front matter to `status: implemented`. Merge `feat/phase1` → `main` via superpowers:finishing-a-development-branch.
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
{
|
||||||
|
"name": "@sttracker/core",
|
||||||
|
"private": true,
|
||||||
|
"type": "module",
|
||||||
|
"exports": {
|
||||||
|
"./*": "./src/*.ts"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@sttracker/db": "workspace:*",
|
||||||
|
"@sttracker/plugin-sdk": "workspace:*",
|
||||||
|
"fflate": "^0.8.3",
|
||||||
|
"kysely": "^0.29.7",
|
||||||
|
"zod": "^4.6.7"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,263 @@
|
|||||||
|
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 { NotFoundError, ValidationError } from "./errors.ts";
|
||||||
|
|
||||||
|
export type Attachment = AttachmentsTable;
|
||||||
|
|
||||||
|
export const MAX_ATTACHMENT_BYTES = 25 * 1024 * 1024;
|
||||||
|
|
||||||
|
/** Lowercase and strip parameters (`; charset=...`). */
|
||||||
|
export function normalizeMime(mime: string): string {
|
||||||
|
return (mime.split(";")[0] ?? "").trim().toLowerCase();
|
||||||
|
}
|
||||||
|
|
||||||
|
export 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);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function sha256Hex(bytes: Uint8Array): string {
|
||||||
|
return new Bun.CryptoHasher("sha256").update(bytes).digest("hex");
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Write bytes to the content-addressed store (idempotent, deduplicated) and return their sha256.
|
||||||
|
* Callers that insert rows afterwards should call it again after committing, since an unlink of
|
||||||
|
* identical bytes in between may have removed the file.
|
||||||
|
*/
|
||||||
|
export async function writeAttachmentFile(dataDir: string, bytes: Uint8Array): Promise<string> {
|
||||||
|
const sha256 = sha256Hex(bytes);
|
||||||
|
const path = pathFor(dataDir, sha256);
|
||||||
|
await withLock(sha256, async () => {
|
||||||
|
if (await Bun.file(path).exists()) return;
|
||||||
|
await mkdir(dirname(path), { recursive: true });
|
||||||
|
const tmp = `${path}.tmp-${newId()}`;
|
||||||
|
try {
|
||||||
|
await Bun.write(tmp, bytes);
|
||||||
|
await rename(tmp, path);
|
||||||
|
} catch (e) {
|
||||||
|
await rm(tmp, { force: true });
|
||||||
|
throw e;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return sha256;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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_ATTACHMENT_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; filename: string } | null> {
|
||||||
|
const row = await db.k
|
||||||
|
.selectFrom("attachments")
|
||||||
|
.select(["sha256", "mime", "filename"])
|
||||||
|
.where("id", "=", id)
|
||||||
|
.where("user_id", "=", userId)
|
||||||
|
.executeTakeFirst();
|
||||||
|
if (!row) return null;
|
||||||
|
return { file: Bun.file(pathFor(dataDir, row.sha256)), mime: row.mime, filename: row.filename };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 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 });
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const LINK_ENTITIES: readonly AttachmentEntity[] = ["entry", "engagement", "copy", "list", "work"];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Throw `NotFoundError` unless `entityId` is a live `entity` the user may attach to: their own entry,
|
||||||
|
* engagement, copy or list, or a work that is shared or theirs. Call before `storeAttachment`.
|
||||||
|
*/
|
||||||
|
export async function requireLinkTarget(db: Db, userId: string, entity: AttachmentEntity, entityId: string): Promise<void> {
|
||||||
|
if (!LINK_ENTITIES.includes(entity)) throw new ValidationError("entity", "invalid");
|
||||||
|
const k = db.k;
|
||||||
|
let found: unknown;
|
||||||
|
switch (entity) {
|
||||||
|
case "entry":
|
||||||
|
found = await k.selectFrom("entries").select("id").where("id", "=", entityId).where("user_id", "=", userId).where("deleted_at", "is", null).executeTakeFirst();
|
||||||
|
break;
|
||||||
|
case "engagement":
|
||||||
|
found = await k
|
||||||
|
.selectFrom("engagements as g")
|
||||||
|
.innerJoin("entries as e", "e.id", "g.entry_id")
|
||||||
|
.select("g.id")
|
||||||
|
.where("g.id", "=", entityId)
|
||||||
|
.where("e.user_id", "=", userId)
|
||||||
|
.where("g.deleted_at", "is", null)
|
||||||
|
.where("e.deleted_at", "is", null)
|
||||||
|
.executeTakeFirst();
|
||||||
|
break;
|
||||||
|
case "copy":
|
||||||
|
found = await k.selectFrom("copies").select("id").where("id", "=", entityId).where("user_id", "=", userId).where("deleted_at", "is", null).executeTakeFirst();
|
||||||
|
break;
|
||||||
|
case "list":
|
||||||
|
found = await k.selectFrom("lists").select("id").where("id", "=", entityId).where("user_id", "=", userId).where("deleted_at", "is", null).executeTakeFirst();
|
||||||
|
break;
|
||||||
|
case "work":
|
||||||
|
found = await k
|
||||||
|
.selectFrom("works")
|
||||||
|
.select("id")
|
||||||
|
.where("id", "=", entityId)
|
||||||
|
.where((eb) => eb.or([eb("owner_user_id", "is", null), eb("owner_user_id", "=", userId)]))
|
||||||
|
.executeTakeFirst();
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
if (!found) throw new NotFoundError(entity);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The links of one of the user's attachments (empty for another user's or a missing attachment). */
|
||||||
|
export async function listAttachmentLinks(
|
||||||
|
db: Db,
|
||||||
|
userId: string,
|
||||||
|
attachmentId: string,
|
||||||
|
): Promise<{ id: string; entity: AttachmentEntity; entityId: string }[]> {
|
||||||
|
const rows = await db.k
|
||||||
|
.selectFrom("attachment_links as l")
|
||||||
|
.innerJoin("attachments as a", "a.id", "l.attachment_id")
|
||||||
|
.select(["l.id", "l.entity", "l.entity_id"])
|
||||||
|
.where("a.id", "=", attachmentId)
|
||||||
|
.where("a.user_id", "=", userId)
|
||||||
|
.orderBy("l.id")
|
||||||
|
.execute();
|
||||||
|
return rows.map((r) => ({ id: r.id, entity: r.entity, entityId: r.entity_id }));
|
||||||
|
}
|
||||||
@@ -0,0 +1,228 @@
|
|||||||
|
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();
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AppTokenInfo {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
scopes: TokenScope[];
|
||||||
|
lastUsedAt: string | null;
|
||||||
|
revokedAt: string | null;
|
||||||
|
createdAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The user's app tokens, newest first. Metadata only: the token and its hash are never returned. */
|
||||||
|
export async function listAppTokens(db: Db, userId: string): Promise<AppTokenInfo[]> {
|
||||||
|
const rows = await db.k
|
||||||
|
.selectFrom("app_tokens")
|
||||||
|
.select(["id", "name", "scopes", "last_used_at", "revoked_at", "created_at"])
|
||||||
|
.where("user_id", "=", userId)
|
||||||
|
.orderBy("created_at", "desc")
|
||||||
|
.orderBy("id", "desc")
|
||||||
|
.execute();
|
||||||
|
return rows.map((r) => ({
|
||||||
|
id: r.id,
|
||||||
|
name: r.name,
|
||||||
|
scopes: fromJson<TokenScope[]>(r.scopes, []),
|
||||||
|
lastUsedAt: r.last_used_at,
|
||||||
|
revokedAt: r.revoked_at,
|
||||||
|
createdAt: r.created_at,
|
||||||
|
}));
|
||||||
|
}
|
||||||
@@ -0,0 +1,59 @@
|
|||||||
|
import { chmod, mkdir, readdir, rename, rm } from "node:fs/promises";
|
||||||
|
import { join } from "node:path";
|
||||||
|
import { sql } from "kysely";
|
||||||
|
import type { Db } from "@sttracker/db/db";
|
||||||
|
import { exportUserBundle } from "./export.ts";
|
||||||
|
|
||||||
|
const STAMP_RE = /^\d{4}-\d{2}-\d{2}T\d{2}-\d{2}-\d{2}\.\d{3}Z$/;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Write `${dataDir}/backups/<ISO>/` with one bundle per user (plus `sttracker.db` on SQLite),
|
||||||
|
* then prune to the newest `keep` (default 14). Returns the new directory.
|
||||||
|
*/
|
||||||
|
export async function runBackup(db: Db, dataDir: string, opts: { keep?: number } = {}): Promise<string> {
|
||||||
|
const keep = Math.max(1, opts.keep ?? 14);
|
||||||
|
const root = join(dataDir, "backups");
|
||||||
|
await mkdir(root, { recursive: true });
|
||||||
|
await chmod(root, 0o700);
|
||||||
|
|
||||||
|
// Two backups in the same millisecond must not share a directory.
|
||||||
|
const stamp = (ms: number) => new Date(ms).toISOString().replaceAll(":", "-");
|
||||||
|
const taken = new Set(await readdir(root));
|
||||||
|
let t = Date.now();
|
||||||
|
while (taken.has(stamp(t))) t++;
|
||||||
|
const dir = join(root, stamp(t));
|
||||||
|
// Build in `<stamp>.tmp` and rename at the end, so a failed run never leaves a counted partial backup.
|
||||||
|
const tmp = `${dir}.tmp`;
|
||||||
|
await rm(tmp, { recursive: true, force: true });
|
||||||
|
await mkdir(tmp);
|
||||||
|
await chmod(tmp, 0o700);
|
||||||
|
try {
|
||||||
|
const users = await db.k.selectFrom("users").select(["id", "username"]).orderBy("id").execute();
|
||||||
|
for (const u of users) {
|
||||||
|
const name = u.username.replace(/[^A-Za-z0-9._-]/g, "_");
|
||||||
|
const file = join(tmp, `${name}.zip`);
|
||||||
|
await Bun.write(file, await exportUserBundle(db, dataDir, u.id));
|
||||||
|
await chmod(file, 0o600);
|
||||||
|
}
|
||||||
|
if (db.dialect === "sqlite") {
|
||||||
|
const file = join(tmp, "sttracker.db");
|
||||||
|
await sql.raw(`VACUUM INTO '${file.replaceAll("'", "''")}'`).execute(db.k);
|
||||||
|
await chmod(file, 0o600);
|
||||||
|
}
|
||||||
|
await rename(tmp, dir);
|
||||||
|
} catch (e) {
|
||||||
|
await rm(tmp, { recursive: true, force: true });
|
||||||
|
throw e;
|
||||||
|
}
|
||||||
|
|
||||||
|
const all = (await readdir(root)).filter((n) => STAMP_RE.test(n)).sort();
|
||||||
|
for (const old of all.slice(0, Math.max(0, all.length - keep))) await rm(join(root, old), { recursive: true, force: true });
|
||||||
|
return dir;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Job handler for the `"backup"` job type. */
|
||||||
|
export function backupHandler(deps: { db: Db; dataDir: string; keep?: number }): () => Promise<void> {
|
||||||
|
return async () => {
|
||||||
|
await runBackup(deps.db, deps.dataDir, { keep: deps.keep });
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,404 @@
|
|||||||
|
import type { Db } from "@sttracker/db/db";
|
||||||
|
import { newId, nowIso } from "@sttracker/db/ids";
|
||||||
|
import { fromJson, toJson } from "@sttracker/db/json";
|
||||||
|
import type { ConnectionStatus, RunMode, RunStatus } from "@sttracker/db/schema";
|
||||||
|
import { createHttpClient, type HttpClientOptions } from "@sttracker/plugin-sdk/http";
|
||||||
|
import type { HttpClient, PluginContext, PluginManifest } from "@sttracker/plugin-sdk/types";
|
||||||
|
import { decryptJson, encryptJson } from "./crypto.ts";
|
||||||
|
import { CryptoError, NotFoundError, ValidationError } from "./errors.ts";
|
||||||
|
import { DropJobError, NonRetryableError, type JobContext } from "./jobs.ts";
|
||||||
|
import type { PluginRegistry } from "./registry.ts";
|
||||||
|
import { runSync, type Connection, type RunSummary, type SyncMode } from "./sync/run.ts";
|
||||||
|
|
||||||
|
const WEEK_MS = 7 * 24 * 60 * 60 * 1000;
|
||||||
|
|
||||||
|
export interface ConnectionView {
|
||||||
|
id: string;
|
||||||
|
pluginId: string;
|
||||||
|
name: string;
|
||||||
|
config: Record<string, unknown>;
|
||||||
|
scheduleMinutes: number | null;
|
||||||
|
status: ConnectionStatus;
|
||||||
|
lastRunAt: string | null;
|
||||||
|
createdAt: string;
|
||||||
|
hasCredentials: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ConnectionInput {
|
||||||
|
pluginId: string;
|
||||||
|
name: string;
|
||||||
|
config: Record<string, unknown>;
|
||||||
|
credentials?: Record<string, string>;
|
||||||
|
scheduleMinutes?: number | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
type Creds = Record<string, string>;
|
||||||
|
|
||||||
|
/** Split incoming config: fields typed `"secret"` in the manifest go to the credentials blob, the rest stay config. */
|
||||||
|
function splitConfig(manifest: PluginManifest, config: Record<string, unknown>): { plain: Record<string, unknown>; secrets: Creds } {
|
||||||
|
const secretKeys = new Set(manifest.config.filter((f) => f.type === "secret").map((f) => f.key));
|
||||||
|
const plain: Record<string, unknown> = {};
|
||||||
|
const secrets: Creds = {};
|
||||||
|
for (const [k, v] of Object.entries(config)) {
|
||||||
|
if (!secretKeys.has(k)) plain[k] = v;
|
||||||
|
else if (typeof v === "string" && v !== "") secrets[k] = v;
|
||||||
|
else if (v !== undefined && v !== null && v !== "") throw new ValidationError(k, "secret_must_be_string");
|
||||||
|
}
|
||||||
|
return { plain, secrets };
|
||||||
|
}
|
||||||
|
|
||||||
|
async function readCreds(secret: string, enc: string | null): Promise<Creds | null> {
|
||||||
|
return enc === null ? null : decryptJson<Creds>(secret, enc);
|
||||||
|
}
|
||||||
|
|
||||||
|
const encOrNull = async (secret: string, c: Creds): Promise<string | null> => (Object.keys(c).length === 0 ? null : encryptJson(secret, c));
|
||||||
|
|
||||||
|
function checkSchedule(m: number | null | undefined): void {
|
||||||
|
if (m !== undefined && m !== null && (!Number.isInteger(m) || m < 1)) throw new ValidationError("scheduleMinutes", "must_be_positive_integer");
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function createConnection(db: Db, secret: string, userId: string, input: ConnectionInput, manifest: PluginManifest): Promise<string> {
|
||||||
|
if (manifest.id !== input.pluginId) throw new ValidationError("pluginId", "manifest_mismatch");
|
||||||
|
if (input.name.trim() === "") throw new ValidationError("name", "required");
|
||||||
|
checkSchedule(input.scheduleMinutes);
|
||||||
|
const { plain, secrets } = splitConfig(manifest, input.config);
|
||||||
|
const id = newId();
|
||||||
|
await db.k
|
||||||
|
.insertInto("source_connections")
|
||||||
|
.values({
|
||||||
|
id,
|
||||||
|
user_id: userId,
|
||||||
|
plugin_id: input.pluginId,
|
||||||
|
name: input.name.trim(),
|
||||||
|
config: toJson(plain),
|
||||||
|
credentials_enc: await encOrNull(secret, { ...secrets, ...input.credentials }),
|
||||||
|
schedule_minutes: input.scheduleMinutes ?? null,
|
||||||
|
cursor: null,
|
||||||
|
status: "active",
|
||||||
|
last_run_at: null,
|
||||||
|
created_at: nowIso(),
|
||||||
|
})
|
||||||
|
.execute();
|
||||||
|
return id;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ConnectionPatch {
|
||||||
|
name?: string;
|
||||||
|
config?: Record<string, unknown>;
|
||||||
|
credentials?: Record<string, string>;
|
||||||
|
scheduleMinutes?: number | null;
|
||||||
|
status?: ConnectionStatus;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Secrets omitted from `config` are kept; plain config is replaced wholesale. Read-modify-write runs in one transaction. */
|
||||||
|
export async function updateConnection(db: Db, secret: string, userId: string, id: string, patch: ConnectionPatch, manifest: PluginManifest): Promise<void> {
|
||||||
|
checkSchedule(patch.scheduleMinutes);
|
||||||
|
await db.k.transaction().execute(async (tx) => {
|
||||||
|
let q = tx.selectFrom("source_connections").selectAll().where("id", "=", id).where("user_id", "=", userId);
|
||||||
|
if (db.dialect === "postgres") q = q.forUpdate();
|
||||||
|
const row = await q.executeTakeFirst();
|
||||||
|
if (!row) throw new NotFoundError("connection");
|
||||||
|
if (row.plugin_id !== manifest.id) throw new ValidationError("pluginId", "manifest_mismatch");
|
||||||
|
const set: Record<string, unknown> = {};
|
||||||
|
if (patch.name !== undefined) {
|
||||||
|
if (patch.name.trim() === "") throw new ValidationError("name", "required");
|
||||||
|
set.name = patch.name.trim();
|
||||||
|
}
|
||||||
|
if (patch.scheduleMinutes !== undefined) set.schedule_minutes = patch.scheduleMinutes;
|
||||||
|
if (patch.status !== undefined) set.status = patch.status;
|
||||||
|
if (patch.config !== undefined || patch.credentials !== undefined) {
|
||||||
|
const split = patch.config ? splitConfig(manifest, patch.config) : { plain: undefined, secrets: {} };
|
||||||
|
if (split.plain) set.config = toJson(split.plain);
|
||||||
|
const merged = { ...((await readCreds(secret, row.credentials_enc)) ?? {}), ...split.secrets, ...patch.credentials };
|
||||||
|
set.credentials_enc = await encOrNull(secret, merged);
|
||||||
|
}
|
||||||
|
if (Object.keys(set).length > 0) await tx.updateTable("source_connections").set(set).where("id", "=", id).execute();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Soft delete: the connection is paused and its credentials are wiped. Records and runs stay. */
|
||||||
|
export async function deleteConnection(db: Db, userId: string, id: string): Promise<void> {
|
||||||
|
const r = await db.k
|
||||||
|
.updateTable("source_connections")
|
||||||
|
.set({ status: "paused", credentials_enc: null })
|
||||||
|
.where("id", "=", id)
|
||||||
|
.where("user_id", "=", userId)
|
||||||
|
.executeTakeFirst();
|
||||||
|
if (r.numUpdatedRows !== 1n) throw new NotFoundError("connection");
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Never returns secrets: plain config plus `hasCredentials`. */
|
||||||
|
export async function listConnections(db: Db, userId: string): Promise<ConnectionView[]> {
|
||||||
|
const rows = await db.k.selectFrom("source_connections").selectAll().where("user_id", "=", userId).orderBy("created_at").execute();
|
||||||
|
return rows.map((r) => ({
|
||||||
|
id: r.id,
|
||||||
|
pluginId: r.plugin_id,
|
||||||
|
name: r.name,
|
||||||
|
config: fromJson<Record<string, unknown>>(r.config, {}),
|
||||||
|
scheduleMinutes: r.schedule_minutes,
|
||||||
|
status: r.status,
|
||||||
|
lastRunAt: r.last_run_at,
|
||||||
|
createdAt: r.created_at,
|
||||||
|
hasCredentials: r.credentials_enc !== null,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A deleted connection is paused with its credentials wiped (`deleteConnection`). */
|
||||||
|
export function isDeletedConnection(c: Pick<ConnectionView, "status" | "hasCredentials">): boolean {
|
||||||
|
return c.status === "paused" && !c.hasCredentials;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function ownedRow(db: Db, userId: string, id: string) {
|
||||||
|
const r = await db.k.selectFrom("source_connections").selectAll().where("id", "=", id).where("user_id", "=", userId).executeTakeFirst();
|
||||||
|
if (!r || (r.status === "paused" && r.credentials_enc === null)) throw new NotFoundError("connection");
|
||||||
|
return r;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** One of the user's live connections, without secrets. Deleted or foreign connections throw `NotFoundError`. */
|
||||||
|
export async function getConnection(db: Db, userId: string, id: string): Promise<ConnectionView> {
|
||||||
|
const r = await ownedRow(db, userId, id);
|
||||||
|
return {
|
||||||
|
id: r.id,
|
||||||
|
pluginId: r.plugin_id,
|
||||||
|
name: r.name,
|
||||||
|
config: fromJson<Record<string, unknown>>(r.config, {}),
|
||||||
|
scheduleMinutes: r.schedule_minutes,
|
||||||
|
status: r.status,
|
||||||
|
lastRunAt: r.last_run_at,
|
||||||
|
createdAt: r.created_at,
|
||||||
|
hasCredentials: r.credentials_enc !== null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RunView {
|
||||||
|
id: string;
|
||||||
|
mode: RunMode;
|
||||||
|
status: RunStatus;
|
||||||
|
startedAt: string;
|
||||||
|
finishedAt: string | null;
|
||||||
|
counts: Record<string, number>;
|
||||||
|
log: string;
|
||||||
|
error: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Runs of one of the user's connections, newest first (at most `limit`, default 50). */
|
||||||
|
export async function listRuns(db: Db, userId: string, connectionId: string, limit = 50): Promise<RunView[]> {
|
||||||
|
await ownedRow(db, userId, connectionId);
|
||||||
|
const rows = await db.k
|
||||||
|
.selectFrom("source_runs")
|
||||||
|
.selectAll()
|
||||||
|
.where("connection_id", "=", connectionId)
|
||||||
|
.orderBy("started_at", "desc")
|
||||||
|
.orderBy("id", "desc")
|
||||||
|
.limit(Math.min(Math.max(limit, 1), 200))
|
||||||
|
.execute();
|
||||||
|
return rows.map((r) => ({
|
||||||
|
id: r.id,
|
||||||
|
mode: r.mode,
|
||||||
|
status: r.status,
|
||||||
|
startedAt: r.started_at,
|
||||||
|
finishedAt: r.finished_at,
|
||||||
|
counts: fromJson<Record<string, number>>(r.counts, {}),
|
||||||
|
log: r.log,
|
||||||
|
error: r.error,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
async function deviceContext(db: Db, secret: string, registry: PluginRegistry, userId: string, id: string) {
|
||||||
|
const r = await ownedRow(db, userId, id);
|
||||||
|
const plugin = registry.get(r.plugin_id);
|
||||||
|
if (!plugin) throw new ValidationError("pluginId", "not_installed");
|
||||||
|
if (!plugin.deviceAuth) throw new ValidationError("pluginId", "no_device_auth");
|
||||||
|
const conn: Connection = {
|
||||||
|
id: r.id,
|
||||||
|
userId: r.user_id,
|
||||||
|
pluginId: r.plugin_id,
|
||||||
|
name: r.name,
|
||||||
|
config: fromJson<Record<string, unknown>>(r.config, {}),
|
||||||
|
credentialsEnc: r.credentials_enc,
|
||||||
|
scheduleMinutes: r.schedule_minutes,
|
||||||
|
cursor: r.cursor,
|
||||||
|
status: r.status,
|
||||||
|
lastRunAt: r.last_run_at,
|
||||||
|
createdAt: r.created_at,
|
||||||
|
};
|
||||||
|
const ctx = await makePluginContext(db, secret, conn, "device", plugin.manifest);
|
||||||
|
return { deviceAuth: plugin.deviceAuth, ctx };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Start a device-code sign-in for one of the user's connections. */
|
||||||
|
export async function deviceStart(db: Db, secret: string, registry: PluginRegistry, userId: string, id: string) {
|
||||||
|
const { deviceAuth, ctx } = await deviceContext(db, secret, registry, userId, id);
|
||||||
|
return deviceAuth.start(ctx);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Poll a device-code sign-in. On success the returned credentials are merged into the encrypted blob. */
|
||||||
|
export async function devicePoll(db: Db, secret: string, registry: PluginRegistry, userId: string, id: string, deviceCode: string): Promise<"pending" | "connected"> {
|
||||||
|
const { deviceAuth, ctx } = await deviceContext(db, secret, registry, userId, id);
|
||||||
|
const res = await deviceAuth.poll(ctx, deviceCode);
|
||||||
|
if (res === "pending") return "pending";
|
||||||
|
await ctx.credentials.save(res);
|
||||||
|
return "connected";
|
||||||
|
}
|
||||||
|
|
||||||
|
export const USER_AGENT = "STTracker/0.1 (+self-hosted)";
|
||||||
|
|
||||||
|
export interface ContextOpts {
|
||||||
|
/** Replaces the rate-limited client entirely. */
|
||||||
|
http?: HttpClient;
|
||||||
|
/** Overrides for the built client (fetch, sleep, clock); used by tests. */
|
||||||
|
httpOptions?: Pick<HttpClientOptions, "fetchImpl" | "sleep" | "now">;
|
||||||
|
signal?: AbortSignal;
|
||||||
|
/** Receives `ctx.log` messages. */
|
||||||
|
onLog?: (msg: string) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Synchronous context builder over already-decrypted credentials. */
|
||||||
|
function buildContext(db: Db, secret: string, conn: Connection, manifest: PluginManifest, initial: Creds | null, opts: ContextOpts): PluginContext {
|
||||||
|
let creds = initial;
|
||||||
|
let cursor = conn.cursor;
|
||||||
|
return {
|
||||||
|
http:
|
||||||
|
opts.http ??
|
||||||
|
createHttpClient({
|
||||||
|
ratePerMinute: manifest.rateLimit?.perMinute ?? 60,
|
||||||
|
burst: manifest.rateLimit?.burst ?? 5,
|
||||||
|
userAgent: USER_AGENT,
|
||||||
|
...opts.httpOptions,
|
||||||
|
}),
|
||||||
|
log: (msg) => opts.onLog?.(msg),
|
||||||
|
config: { ...conn.config, ...(initial ?? {}) },
|
||||||
|
credentials: {
|
||||||
|
get: async () => creds,
|
||||||
|
async save(c) {
|
||||||
|
// Read the current blob, merge, and write in one transaction so concurrent saves cannot lose keys.
|
||||||
|
creds = await db.k.transaction().execute(async (tx) => {
|
||||||
|
let q = tx.selectFrom("source_connections").select("credentials_enc").where("id", "=", conn.id);
|
||||||
|
if (db.dialect === "postgres") q = q.forUpdate();
|
||||||
|
const row = await q.executeTakeFirstOrThrow();
|
||||||
|
const merged = { ...((await readCreds(secret, row.credentials_enc)) ?? {}), ...c };
|
||||||
|
await tx.updateTable("source_connections").set({ credentials_enc: await encryptJson(secret, merged) }).where("id", "=", conn.id).execute();
|
||||||
|
return merged;
|
||||||
|
});
|
||||||
|
},
|
||||||
|
},
|
||||||
|
cursor: {
|
||||||
|
get: async () => cursor,
|
||||||
|
async set(c) {
|
||||||
|
await db.k.updateTable("source_connections").set({ cursor: c }).where("id", "=", conn.id).execute();
|
||||||
|
cursor = c;
|
||||||
|
},
|
||||||
|
},
|
||||||
|
now: () => new Date(),
|
||||||
|
signal: opts.signal ?? new AbortController().signal,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build the plugin context for one run. `ctx.http` is rate limited from `manifest.rateLimit` (default 60/min, burst 5)
|
||||||
|
* unless `opts.http` is given. `credentials.save` merges into the current blob and persists it encrypted immediately;
|
||||||
|
* `cursor.set` persists immediately.
|
||||||
|
*/
|
||||||
|
export async function makePluginContext(db: Db, secret: string, conn: Connection, _runId: string, manifest: PluginManifest, opts: ContextOpts = {}): Promise<PluginContext> {
|
||||||
|
return buildContext(db, secret, conn, manifest, await readCreds(secret, conn.credentialsEnc), opts);
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SyncHandlerDeps {
|
||||||
|
db: Db;
|
||||||
|
secret: string;
|
||||||
|
registry: PluginRegistry;
|
||||||
|
http?: HttpClient;
|
||||||
|
}
|
||||||
|
|
||||||
|
const MSG_CRYPTO = "Cannot decrypt this source's credentials. Was STTRACKER_SECRET changed? Reconnect the source to fix it.";
|
||||||
|
|
||||||
|
/** Record a run that failed before `runSync` could start, flag the connection, and stop the job without retry. */
|
||||||
|
async function failBeforeRun(db: Db, connectionId: string, mode: RunMode, message: string): Promise<never> {
|
||||||
|
const now = nowIso();
|
||||||
|
await db.k
|
||||||
|
.insertInto("source_runs")
|
||||||
|
.values({ id: newId(), connection_id: connectionId, mode, started_at: now, finished_at: now, status: "failed", counts: "{}", log: message, error: message })
|
||||||
|
.execute();
|
||||||
|
await db.k.updateTable("source_connections").set({ status: "error", last_run_at: now }).where("id", "=", connectionId).execute();
|
||||||
|
throw new NonRetryableError(message);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The `"sync"` job handler. Payload `{ connectionId, mode? }`. Runs `full` when asked, when the plugin cannot
|
||||||
|
* `pullChanges`, or when the last successful full run is over a week old; otherwise `changes`.
|
||||||
|
* A missing or paused connection drops the job. A missing plugin or undecryptable credentials record a failed
|
||||||
|
* run, mark the connection `error` and fail the job without retry. A failed run is rethrown so the worker retries.
|
||||||
|
* `ctx.log` output is appended to the run's `source_runs.log`.
|
||||||
|
*/
|
||||||
|
export interface SyncPayload {
|
||||||
|
connectionId: string;
|
||||||
|
mode?: "full" | "changes";
|
||||||
|
/** File mode: run the plugin's `importFile` over these bytes instead of pulling. */
|
||||||
|
file?: { name: string; base64: string };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function syncHandler(deps: SyncHandlerDeps): (payload: SyncPayload, jc?: JobContext) => Promise<RunSummary | void> {
|
||||||
|
return async (payload, jc) => {
|
||||||
|
const { db } = deps;
|
||||||
|
const full = await db.k.selectFrom("source_connections").selectAll().where("id", "=", payload.connectionId).executeTakeFirst();
|
||||||
|
if (!full) throw new DropJobError("connection not found");
|
||||||
|
if (full.status === "paused") throw new DropJobError("connection is paused");
|
||||||
|
const plugin = deps.registry.get(full.plugin_id);
|
||||||
|
if (!plugin) return failBeforeRun(db, full.id, payload.file ? "file" : (payload.mode ?? "full"), `Plugin '${full.plugin_id}' is not installed or failed to load.`);
|
||||||
|
let mode: SyncMode = "full";
|
||||||
|
if (payload.file) {
|
||||||
|
mode = { file: { name: payload.file.name, bytes: new Uint8Array(Buffer.from(payload.file.base64, "base64")) } };
|
||||||
|
} else if (plugin.pullChanges && payload.mode !== "full") {
|
||||||
|
const cutoff = new Date(Date.now() - WEEK_MS).toISOString();
|
||||||
|
const recentFull = await db.k
|
||||||
|
.selectFrom("source_runs")
|
||||||
|
.select("id")
|
||||||
|
.where("connection_id", "=", full.id)
|
||||||
|
.where("mode", "=", "full")
|
||||||
|
.where("status", "=", "ok")
|
||||||
|
.where("started_at", ">=", cutoff)
|
||||||
|
.executeTakeFirst();
|
||||||
|
if (recentFull) mode = "changes";
|
||||||
|
}
|
||||||
|
const conn: Connection = {
|
||||||
|
id: full.id,
|
||||||
|
userId: full.user_id,
|
||||||
|
pluginId: full.plugin_id,
|
||||||
|
name: full.name,
|
||||||
|
config: fromJson<Record<string, unknown>>(full.config, {}),
|
||||||
|
credentialsEnc: full.credentials_enc,
|
||||||
|
scheduleMinutes: full.schedule_minutes,
|
||||||
|
cursor: full.cursor,
|
||||||
|
status: full.status,
|
||||||
|
lastRunAt: full.last_run_at,
|
||||||
|
createdAt: full.created_at,
|
||||||
|
};
|
||||||
|
let creds: Creds | null;
|
||||||
|
try {
|
||||||
|
creds = await readCreds(deps.secret, conn.credentialsEnc);
|
||||||
|
} catch (e) {
|
||||||
|
if (e instanceof CryptoError) return failBeforeRun(db, full.id, typeof mode === "string" ? mode : "file", MSG_CRYPTO);
|
||||||
|
throw e;
|
||||||
|
}
|
||||||
|
const logs: string[] = [];
|
||||||
|
let runId: string | null = null;
|
||||||
|
const makeContext = (c: Connection, id: string) => {
|
||||||
|
runId = id;
|
||||||
|
return buildContext(db, deps.secret, c, plugin.manifest, creds, { http: deps.http, signal: jc?.signal, onLog: (m) => void logs.push(m) });
|
||||||
|
};
|
||||||
|
let summary: RunSummary;
|
||||||
|
try {
|
||||||
|
summary = await runSync({ db, makeContext }, plugin, full.id, mode);
|
||||||
|
} finally {
|
||||||
|
if (runId !== null && logs.length > 0) {
|
||||||
|
const cur = await db.k.selectFrom("source_runs").select("log").where("id", "=", runId).executeTakeFirst();
|
||||||
|
if (cur) {
|
||||||
|
const log = cur.log === "" ? logs.join("\n") : `${logs.join("\n")}\n${cur.log}`;
|
||||||
|
await db.k.updateTable("source_runs").set({ log }).where("id", "=", runId).execute();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (summary.status === "failed") throw new Error(summary.error ?? "sync failed");
|
||||||
|
return summary;
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,247 @@
|
|||||||
|
import type { Db } from "@sttracker/db/db";
|
||||||
|
import { newId, nowIso } from "@sttracker/db/ids";
|
||||||
|
import { fromJson, toJson } from "@sttracker/db/json";
|
||||||
|
import type { Database } from "@sttracker/db/schema";
|
||||||
|
import type { PartialDate } from "@sttracker/plugin-sdk/types";
|
||||||
|
import { customFieldsFor, validateCustomValues } from "./custom-fields.ts";
|
||||||
|
import { checkKeys, checkText, fromStoredDate, resolveVisibleWork, toStoredDate } from "./entries.ts";
|
||||||
|
import { NotFoundError, ValidationError } from "./errors.ts";
|
||||||
|
import { type Actor, type Executor, inTx, recordHistory } from "./history.ts";
|
||||||
|
|
||||||
|
/** An owned copy (spec §6.7). Always private. `workId` is null until matched; then `titleHint` names it. */
|
||||||
|
export interface CopyPatch {
|
||||||
|
workId: string | null;
|
||||||
|
titleHint: string | null;
|
||||||
|
format: string | null;
|
||||||
|
acquiredFrom: string | null;
|
||||||
|
acquiredAt: PartialDate | null;
|
||||||
|
/** Integer minor units (cents). */
|
||||||
|
priceCents: number | null;
|
||||||
|
/** ISO 4217 code, e.g. "GBP". */
|
||||||
|
currency: string | null;
|
||||||
|
notes: string | null;
|
||||||
|
customFields: Record<string, unknown>;
|
||||||
|
sourceRef: string | null;
|
||||||
|
}
|
||||||
|
const COPY_KEYS = [
|
||||||
|
"workId",
|
||||||
|
"titleHint",
|
||||||
|
"format",
|
||||||
|
"acquiredFrom",
|
||||||
|
"acquiredAt",
|
||||||
|
"priceCents",
|
||||||
|
"currency",
|
||||||
|
"notes",
|
||||||
|
"customFields",
|
||||||
|
"sourceRef",
|
||||||
|
] as const satisfies readonly (keyof CopyPatch)[];
|
||||||
|
|
||||||
|
export interface Copy extends CopyPatch {
|
||||||
|
id: string;
|
||||||
|
userId: string;
|
||||||
|
createdAt: string;
|
||||||
|
updatedAt: string;
|
||||||
|
deletedAt: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
type Row = Database["copies"];
|
||||||
|
|
||||||
|
function toCopy(r: Row): Copy {
|
||||||
|
return {
|
||||||
|
id: r.id,
|
||||||
|
userId: r.user_id,
|
||||||
|
workId: r.work_id,
|
||||||
|
titleHint: r.title_hint,
|
||||||
|
format: r.format,
|
||||||
|
acquiredFrom: r.acquired_from,
|
||||||
|
acquiredAt: fromStoredDate(r.acquired_at, r.acquired_precision),
|
||||||
|
priceCents: r.price_cents,
|
||||||
|
currency: r.currency,
|
||||||
|
notes: r.notes,
|
||||||
|
customFields: fromJson<Record<string, unknown>>(r.custom_fields, {}),
|
||||||
|
sourceRef: r.source_ref,
|
||||||
|
createdAt: r.created_at,
|
||||||
|
updatedAt: r.updated_at,
|
||||||
|
deletedAt: r.deleted_at,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function snapshot(c: Copy): Record<string, unknown> {
|
||||||
|
const s: Record<string, unknown> = { deletedAt: c.deletedAt };
|
||||||
|
for (const k of COPY_KEYS) s[k] = c[k];
|
||||||
|
return s;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Validate a patch into columns. `workId` must be visible to the user; custom fields use copy-level definitions. */
|
||||||
|
async function toColumns(
|
||||||
|
k: Executor,
|
||||||
|
userId: string,
|
||||||
|
patch: Partial<CopyPatch>,
|
||||||
|
currentWorkId: string | null,
|
||||||
|
): Promise<Partial<Row>> {
|
||||||
|
checkKeys("patch", patch, COPY_KEYS);
|
||||||
|
const c: Partial<Row> = {};
|
||||||
|
let workId = currentWorkId;
|
||||||
|
if ("workId" in patch) {
|
||||||
|
workId = patch.workId === null ? null : (await resolveVisibleWork(k, userId, String(patch.workId))).id;
|
||||||
|
c.work_id = workId;
|
||||||
|
}
|
||||||
|
if ("titleHint" in patch) c.title_hint = checkText("titleHint", patch.titleHint);
|
||||||
|
if ("format" in patch) c.format = checkText("format", patch.format);
|
||||||
|
if ("acquiredFrom" in patch) c.acquired_from = checkText("acquiredFrom", patch.acquiredFrom);
|
||||||
|
if ("acquiredAt" in patch) {
|
||||||
|
const d = toStoredDate("acquiredAt", patch.acquiredAt);
|
||||||
|
c.acquired_at = d.value;
|
||||||
|
c.acquired_precision = d.precision;
|
||||||
|
}
|
||||||
|
if ("priceCents" in patch) {
|
||||||
|
const p = patch.priceCents;
|
||||||
|
if (p !== null && (typeof p !== "number" || !Number.isInteger(p) || p < 0)) {
|
||||||
|
throw new ValidationError("priceCents", "not_cents");
|
||||||
|
}
|
||||||
|
c.price_cents = p;
|
||||||
|
}
|
||||||
|
if ("currency" in patch) {
|
||||||
|
const cur = checkText("currency", patch.currency);
|
||||||
|
if (cur !== null && !/^[A-Z]{3}$/.test(cur)) throw new ValidationError("currency", "invalid");
|
||||||
|
c.currency = cur;
|
||||||
|
}
|
||||||
|
if ("notes" in patch) c.notes = checkText("notes", patch.notes);
|
||||||
|
if ("sourceRef" in patch) c.source_ref = checkText("sourceRef", patch.sourceRef);
|
||||||
|
if ("customFields" in patch) {
|
||||||
|
let mediaType: string | null = null;
|
||||||
|
if (workId) {
|
||||||
|
const w = await k.selectFrom("works").select("media_type").where("id", "=", workId).executeTakeFirst();
|
||||||
|
mediaType = w?.media_type ?? null;
|
||||||
|
}
|
||||||
|
const defs = await customFieldsFor(k, userId, { mediaType, level: "copy" });
|
||||||
|
validateCustomValues(defs, patch.customFields as Record<string, unknown>);
|
||||||
|
c.custom_fields = toJson(patch.customFields);
|
||||||
|
}
|
||||||
|
return c;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function load(k: Executor, userId: string, copyId: string, includeDeleted = false): Promise<Copy> {
|
||||||
|
let q = k.selectFrom("copies").selectAll().where("id", "=", copyId).where("user_id", "=", userId);
|
||||||
|
if (!includeDeleted) q = q.where("deleted_at", "is", null);
|
||||||
|
const r = await q.executeTakeFirst();
|
||||||
|
if (!r) throw new NotFoundError("copy");
|
||||||
|
return toCopy(r);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function write(
|
||||||
|
k: Executor,
|
||||||
|
current: Copy,
|
||||||
|
patch: Partial<CopyPatch>,
|
||||||
|
actor: Actor,
|
||||||
|
extra: Partial<Pick<Row, "deleted_at">> = {},
|
||||||
|
): Promise<Copy> {
|
||||||
|
const cols = await toColumns(k, current.userId, patch, current.workId);
|
||||||
|
const next = { ...current, ...patchFromCols(current, cols) };
|
||||||
|
if (next.workId === null && !next.titleHint?.trim()) throw new ValidationError("titleHint", "required");
|
||||||
|
await k
|
||||||
|
.updateTable("copies")
|
||||||
|
.set({ ...cols, ...extra, updated_at: nowIso() })
|
||||||
|
.where("id", "=", current.id)
|
||||||
|
.execute();
|
||||||
|
const after = await load(k, current.userId, current.id, true);
|
||||||
|
await recordHistory(k, {
|
||||||
|
userId: current.userId,
|
||||||
|
entity: "copy",
|
||||||
|
entityId: current.id,
|
||||||
|
before: snapshot(current),
|
||||||
|
after: snapshot(after),
|
||||||
|
actor,
|
||||||
|
});
|
||||||
|
return after;
|
||||||
|
}
|
||||||
|
|
||||||
|
function patchFromCols(current: Copy, cols: Partial<Row>): Pick<Copy, "workId" | "titleHint"> {
|
||||||
|
return {
|
||||||
|
workId: "work_id" in cols ? (cols.work_id ?? null) : current.workId,
|
||||||
|
titleHint: "title_hint" in cols ? (cols.title_hint ?? null) : current.titleHint,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Create an owned copy. Needs a `workId` or a `titleHint`. Writes history. */
|
||||||
|
export async function createCopy(
|
||||||
|
db: Db,
|
||||||
|
userId: string,
|
||||||
|
input: Partial<CopyPatch>,
|
||||||
|
actor: Actor = "user",
|
||||||
|
tx?: Executor,
|
||||||
|
): Promise<Copy> {
|
||||||
|
return inTx(db, tx, async (k) => {
|
||||||
|
const cols = await toColumns(k, userId, input, null);
|
||||||
|
if (!cols.work_id && !cols.title_hint?.trim()) throw new ValidationError("titleHint", "required");
|
||||||
|
const now = nowIso();
|
||||||
|
const row: Row = {
|
||||||
|
id: newId(),
|
||||||
|
user_id: userId,
|
||||||
|
work_id: null,
|
||||||
|
title_hint: null,
|
||||||
|
format: null,
|
||||||
|
acquired_from: null,
|
||||||
|
acquired_at: null,
|
||||||
|
acquired_precision: "unknown",
|
||||||
|
price_cents: null,
|
||||||
|
currency: null,
|
||||||
|
notes: null,
|
||||||
|
custom_fields: toJson({}),
|
||||||
|
source_ref: null,
|
||||||
|
...cols,
|
||||||
|
created_at: now,
|
||||||
|
updated_at: now,
|
||||||
|
deleted_at: null,
|
||||||
|
};
|
||||||
|
await k.insertInto("copies").values(row).execute();
|
||||||
|
const copy = toCopy(row);
|
||||||
|
await recordHistory(k, { userId, entity: "copy", entityId: copy.id, before: null, after: snapshot(copy), actor });
|
||||||
|
return copy;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Patch a live copy; only the keys present are touched. Writes history. */
|
||||||
|
export async function updateCopy(
|
||||||
|
db: Db,
|
||||||
|
userId: string,
|
||||||
|
copyId: string,
|
||||||
|
patch: Partial<CopyPatch>,
|
||||||
|
actor: Actor = "user",
|
||||||
|
tx?: Executor,
|
||||||
|
): Promise<Copy> {
|
||||||
|
return inTx(db, tx, async (k) => write(k, await load(k, userId, copyId), patch, actor));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Soft-delete a copy. Writes history. */
|
||||||
|
export async function softDeleteCopy(
|
||||||
|
db: Db,
|
||||||
|
userId: string,
|
||||||
|
copyId: string,
|
||||||
|
actor: Actor = "user",
|
||||||
|
tx?: Executor,
|
||||||
|
): Promise<void> {
|
||||||
|
await inTx(db, tx, async (k) => write(k, await load(k, userId, copyId), {}, actor, { deleted_at: nowIso() }));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The user's live copies, optionally of one work, newest first. */
|
||||||
|
export async function listCopies(db: Db, userId: string, filter: { workId?: string } = {}): Promise<Copy[]> {
|
||||||
|
let q = db.k.selectFrom("copies").selectAll().where("user_id", "=", userId).where("deleted_at", "is", null);
|
||||||
|
if (filter.workId) q = q.where("work_id", "=", filter.workId);
|
||||||
|
const rows = await q.orderBy("created_at", "desc").orderBy("id", "desc").execute();
|
||||||
|
return rows.map(toCopy);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Called by `restoreVersion`: re-apply a history row's `before` values as actor "user". */
|
||||||
|
export async function restoreCopyFromHistory(
|
||||||
|
k: Executor,
|
||||||
|
userId: string,
|
||||||
|
copyId: string,
|
||||||
|
before: Record<string, unknown>,
|
||||||
|
): Promise<void> {
|
||||||
|
const current = await load(k, userId, copyId, true);
|
||||||
|
const patch: Record<string, unknown> = {};
|
||||||
|
for (const key of COPY_KEYS) if (key in before) patch[key] = before[key];
|
||||||
|
const extra = "deletedAt" in before ? { deleted_at: (before.deletedAt as string | null) ?? null } : {};
|
||||||
|
await write(k, current, patch as Partial<CopyPatch>, "user", extra);
|
||||||
|
}
|
||||||
@@ -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,176 @@
|
|||||||
|
import type { Kysely } from "kysely";
|
||||||
|
import type { Db } from "@sttracker/db/db";
|
||||||
|
import { newId, nowIso } from "@sttracker/db/ids";
|
||||||
|
import { fromJson, toJson } from "@sttracker/db/json";
|
||||||
|
import type { CustomFieldLevel, Database } from "@sttracker/db/schema";
|
||||||
|
import { parsePartialDate } from "@sttracker/plugin-sdk/partial-date";
|
||||||
|
import type { MediaType } from "@sttracker/plugin-sdk/types";
|
||||||
|
import { ValidationError } from "./errors.ts";
|
||||||
|
|
||||||
|
export const CUSTOM_FIELD_TYPES = [
|
||||||
|
"text",
|
||||||
|
"long_text",
|
||||||
|
"number",
|
||||||
|
"date",
|
||||||
|
"boolean",
|
||||||
|
"single_select",
|
||||||
|
"multi_select",
|
||||||
|
"url",
|
||||||
|
"rating",
|
||||||
|
] as const;
|
||||||
|
export type CustomFieldType = (typeof CUSTOM_FIELD_TYPES)[number];
|
||||||
|
|
||||||
|
const LEVELS: readonly CustomFieldLevel[] = ["entry", "engagement", "copy"];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A user-defined field. Values live in the `custom_fields` JSON of the matching row
|
||||||
|
* (entry, engagement or copy), keyed by definition **id** so renaming a field keeps its values.
|
||||||
|
*/
|
||||||
|
export interface CustomFieldDef {
|
||||||
|
id: string;
|
||||||
|
userId: string;
|
||||||
|
name: string;
|
||||||
|
type: CustomFieldType;
|
||||||
|
options: string[];
|
||||||
|
appliesTo: string[] | "all";
|
||||||
|
level: CustomFieldLevel;
|
||||||
|
createdAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CustomFieldInput {
|
||||||
|
name: string;
|
||||||
|
type: CustomFieldType;
|
||||||
|
options?: string[];
|
||||||
|
appliesTo?: MediaType[] | string[] | "all";
|
||||||
|
level?: CustomFieldLevel;
|
||||||
|
}
|
||||||
|
|
||||||
|
type Row = Database["custom_field_definitions"];
|
||||||
|
|
||||||
|
function toDef(r: { [K in keyof Row]: Row[K] }): CustomFieldDef {
|
||||||
|
return {
|
||||||
|
id: r.id,
|
||||||
|
userId: r.user_id,
|
||||||
|
name: r.name,
|
||||||
|
type: r.type as CustomFieldType,
|
||||||
|
options: fromJson<string[]>(r.options, []),
|
||||||
|
appliesTo: fromJson<string[] | "all">(r.applies_to, "all"),
|
||||||
|
level: r.level,
|
||||||
|
createdAt: r.created_at,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Create a custom field definition. Names are unique per user and level (case-insensitive). */
|
||||||
|
export async function defineCustomField(db: Db, userId: string, input: CustomFieldInput): Promise<CustomFieldDef> {
|
||||||
|
const name = typeof input.name === "string" ? input.name.trim() : "";
|
||||||
|
if (!name) throw new ValidationError("name", "required");
|
||||||
|
if (!CUSTOM_FIELD_TYPES.includes(input.type)) throw new ValidationError("type", "invalid");
|
||||||
|
const level = input.level ?? "entry";
|
||||||
|
if (!LEVELS.includes(level)) throw new ValidationError("level", "invalid");
|
||||||
|
const isSelect = input.type === "single_select" || input.type === "multi_select";
|
||||||
|
const options = [...new Set((input.options ?? []).map((o) => String(o).trim()).filter(Boolean))];
|
||||||
|
if (isSelect && options.length === 0) throw new ValidationError("options", "required");
|
||||||
|
const appliesTo = input.appliesTo ?? "all";
|
||||||
|
if (appliesTo !== "all" && (!Array.isArray(appliesTo) || appliesTo.length === 0)) {
|
||||||
|
throw new ValidationError("appliesTo", "invalid");
|
||||||
|
}
|
||||||
|
|
||||||
|
const existing = await db.k
|
||||||
|
.selectFrom("custom_field_definitions")
|
||||||
|
.select("name")
|
||||||
|
.where("user_id", "=", userId)
|
||||||
|
.where("level", "=", level)
|
||||||
|
.execute();
|
||||||
|
if (existing.some((r) => r.name.toLowerCase() === name.toLowerCase())) {
|
||||||
|
throw new ValidationError("name", "duplicate");
|
||||||
|
}
|
||||||
|
|
||||||
|
const row: Row = {
|
||||||
|
id: newId(),
|
||||||
|
user_id: userId,
|
||||||
|
name,
|
||||||
|
type: input.type,
|
||||||
|
options: toJson(isSelect ? options : []),
|
||||||
|
applies_to: toJson(appliesTo),
|
||||||
|
level,
|
||||||
|
created_at: nowIso(),
|
||||||
|
};
|
||||||
|
await db.k.insertInto("custom_field_definitions").values(row).execute();
|
||||||
|
return toDef(row);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A user's definitions, optionally only those applying to `mediaType` (and/or one level). */
|
||||||
|
export async function listCustomFields(
|
||||||
|
db: Db,
|
||||||
|
userId: string,
|
||||||
|
mediaType?: string,
|
||||||
|
level?: CustomFieldLevel,
|
||||||
|
): Promise<CustomFieldDef[]> {
|
||||||
|
return customFieldsFor(db.k, userId, { mediaType, level });
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Same as `listCustomFields`, usable with a transaction handle. */
|
||||||
|
export async function customFieldsFor(
|
||||||
|
k: Kysely<Database>,
|
||||||
|
userId: string,
|
||||||
|
opts: { mediaType?: string | null; level?: CustomFieldLevel },
|
||||||
|
): Promise<CustomFieldDef[]> {
|
||||||
|
let q = k.selectFrom("custom_field_definitions").selectAll().where("user_id", "=", userId);
|
||||||
|
if (opts.level) q = q.where("level", "=", opts.level);
|
||||||
|
const rows = await q.orderBy("created_at").orderBy("id").execute();
|
||||||
|
return rows
|
||||||
|
.map(toDef)
|
||||||
|
.filter((d) => opts.mediaType == null || d.appliesTo === "all" || d.appliesTo.includes(opts.mediaType));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check `values` (keyed by definition id) against `defs`. `null` clears a value and is always allowed.
|
||||||
|
* Throws `ValidationError("customFields.<name or key>", reason)`.
|
||||||
|
*/
|
||||||
|
export function validateCustomValues(defs: CustomFieldDef[], values: Record<string, unknown>): void {
|
||||||
|
if (values === null || typeof values !== "object" || Array.isArray(values)) {
|
||||||
|
throw new ValidationError("customFields", "not_object");
|
||||||
|
}
|
||||||
|
const byId = new Map(defs.map((d) => [d.id, d]));
|
||||||
|
for (const [key, v] of Object.entries(values)) {
|
||||||
|
const def = byId.get(key);
|
||||||
|
if (!def) throw new ValidationError(`customFields.${key}`, "unknown_field");
|
||||||
|
if (v === null) continue;
|
||||||
|
const reason = checkValue(def, v);
|
||||||
|
if (reason) throw new ValidationError(`customFields.${def.name}`, reason);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function checkValue(def: CustomFieldDef, v: unknown): string | null {
|
||||||
|
switch (def.type) {
|
||||||
|
case "text":
|
||||||
|
case "long_text":
|
||||||
|
return typeof v === "string" ? null : "not_text";
|
||||||
|
case "number":
|
||||||
|
return typeof v === "number" && Number.isFinite(v) ? null : "not_number";
|
||||||
|
case "date":
|
||||||
|
return typeof v === "string" && parsePartialDate(v) ? null : "not_date";
|
||||||
|
case "boolean":
|
||||||
|
return typeof v === "boolean" ? null : "not_boolean";
|
||||||
|
case "single_select":
|
||||||
|
if (typeof v !== "string") return "not_text";
|
||||||
|
return def.options.includes(v) ? null : "unknown_option";
|
||||||
|
case "multi_select":
|
||||||
|
if (!Array.isArray(v)) return "not_list";
|
||||||
|
if (!v.every((x) => typeof x === "string" && def.options.includes(x))) return "unknown_option";
|
||||||
|
return new Set(v).size === v.length ? null : "duplicate_option";
|
||||||
|
case "url":
|
||||||
|
return typeof v === "string" && isHttpUrl(v) ? null : "not_url";
|
||||||
|
case "rating":
|
||||||
|
return typeof v === "number" && Number.isInteger(v) && v >= 0 && v <= 100 ? null : "out_of_range";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function isHttpUrl(s: string): boolean {
|
||||||
|
try {
|
||||||
|
const u = new URL(s);
|
||||||
|
return u.protocol === "http:" || u.protocol === "https:";
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,276 @@
|
|||||||
|
import { sql } from "kysely";
|
||||||
|
import type { Db } from "@sttracker/db/db";
|
||||||
|
import { newId, nowIso } from "@sttracker/db/ids";
|
||||||
|
import { fromJson, toJson } from "@sttracker/db/json";
|
||||||
|
import type { Database } from "@sttracker/db/schema";
|
||||||
|
import type { PartialDate } from "@sttracker/plugin-sdk/types";
|
||||||
|
import { customFieldsFor, validateCustomValues } from "./custom-fields.ts";
|
||||||
|
import {
|
||||||
|
checkKeys,
|
||||||
|
checkRating,
|
||||||
|
checkText,
|
||||||
|
type FieldSources,
|
||||||
|
fromStoredDate,
|
||||||
|
requireEntry,
|
||||||
|
toStoredDate,
|
||||||
|
} from "./entries.ts";
|
||||||
|
import { NotFoundError, ValidationError } from "./errors.ts";
|
||||||
|
import { type Actor, type Executor, inTx, recordHistory } from "./history.ts";
|
||||||
|
|
||||||
|
/** One time through a work (spec §6.3). Dates are partial; `null` means unknown. */
|
||||||
|
export interface EngagementPatch {
|
||||||
|
startedAt: PartialDate | null;
|
||||||
|
finishedAt: PartialDate | null;
|
||||||
|
progress: Record<string, number | string>;
|
||||||
|
platform: string | null;
|
||||||
|
rating: number | null;
|
||||||
|
notes: string | null;
|
||||||
|
customFields: Record<string, unknown>;
|
||||||
|
}
|
||||||
|
export const ENGAGEMENT_PATCH_KEYS = [
|
||||||
|
"startedAt",
|
||||||
|
"finishedAt",
|
||||||
|
"progress",
|
||||||
|
"platform",
|
||||||
|
"rating",
|
||||||
|
"notes",
|
||||||
|
"customFields",
|
||||||
|
] as const satisfies readonly (keyof EngagementPatch)[];
|
||||||
|
|
||||||
|
export interface Engagement extends EngagementPatch {
|
||||||
|
id: string;
|
||||||
|
entryId: string;
|
||||||
|
fieldSources: FieldSources;
|
||||||
|
createdAt: string;
|
||||||
|
updatedAt: string;
|
||||||
|
deletedAt: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
type Row = Database["engagements"];
|
||||||
|
|
||||||
|
function toEngagement(r: Row): Engagement {
|
||||||
|
return {
|
||||||
|
id: r.id,
|
||||||
|
entryId: r.entry_id,
|
||||||
|
startedAt: fromStoredDate(r.started_at, r.started_precision),
|
||||||
|
finishedAt: fromStoredDate(r.finished_at, r.finished_precision),
|
||||||
|
progress: fromJson<Record<string, number | string>>(r.progress, {}),
|
||||||
|
platform: r.platform,
|
||||||
|
rating: r.rating,
|
||||||
|
notes: r.notes,
|
||||||
|
customFields: fromJson<Record<string, unknown>>(r.custom_fields, {}),
|
||||||
|
fieldSources: fromJson<FieldSources>(r.field_sources, {}),
|
||||||
|
createdAt: r.created_at,
|
||||||
|
updatedAt: r.updated_at,
|
||||||
|
deletedAt: r.deleted_at,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function snapshot(e: Engagement): Record<string, unknown> {
|
||||||
|
const s: Record<string, unknown> = { entryId: e.entryId, deletedAt: e.deletedAt };
|
||||||
|
for (const k of ENGAGEMENT_PATCH_KEYS) s[k] = e[k];
|
||||||
|
return s;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Validate a patch and turn it into columns. */
|
||||||
|
async function toColumns(
|
||||||
|
k: Executor,
|
||||||
|
userId: string,
|
||||||
|
mediaType: string,
|
||||||
|
patch: Partial<EngagementPatch>,
|
||||||
|
): Promise<Partial<Row>> {
|
||||||
|
checkKeys("patch", patch, ENGAGEMENT_PATCH_KEYS);
|
||||||
|
const c: Partial<Row> = {};
|
||||||
|
if ("startedAt" in patch) {
|
||||||
|
const d = toStoredDate("startedAt", patch.startedAt);
|
||||||
|
c.started_at = d.value;
|
||||||
|
c.started_precision = d.precision;
|
||||||
|
}
|
||||||
|
if ("finishedAt" in patch) {
|
||||||
|
const d = toStoredDate("finishedAt", patch.finishedAt);
|
||||||
|
c.finished_at = d.value;
|
||||||
|
c.finished_precision = d.precision;
|
||||||
|
}
|
||||||
|
if ("progress" in patch) {
|
||||||
|
const p = patch.progress;
|
||||||
|
if (p === null || typeof p !== "object" || Array.isArray(p)) throw new ValidationError("progress", "not_object");
|
||||||
|
for (const [key, v] of Object.entries(p)) {
|
||||||
|
if (typeof v !== "string" && (typeof v !== "number" || !Number.isFinite(v))) {
|
||||||
|
throw new ValidationError(`progress.${key}`, "invalid");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
c.progress = toJson(p);
|
||||||
|
}
|
||||||
|
if ("platform" in patch) c.platform = checkText("platform", patch.platform);
|
||||||
|
if ("rating" in patch) c.rating = checkRating("rating", patch.rating);
|
||||||
|
if ("notes" in patch) c.notes = checkText("notes", patch.notes);
|
||||||
|
if ("customFields" in patch) {
|
||||||
|
const defs = await customFieldsFor(k, userId, { mediaType, level: "engagement" });
|
||||||
|
validateCustomValues(defs, patch.customFields as Record<string, unknown>);
|
||||||
|
c.custom_fields = toJson(patch.customFields);
|
||||||
|
}
|
||||||
|
return c;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Load an engagement whose entry belongs to the user. */
|
||||||
|
async function load(
|
||||||
|
k: Executor,
|
||||||
|
userId: string,
|
||||||
|
engagementId: string,
|
||||||
|
opts: { includeDeleted?: boolean } = {},
|
||||||
|
): Promise<{ eg: Engagement; mediaType: string }> {
|
||||||
|
let q = k
|
||||||
|
.selectFrom("engagements as g")
|
||||||
|
.innerJoin("entries as e", "e.id", "g.entry_id")
|
||||||
|
.innerJoin("works as w", "w.id", "e.work_id")
|
||||||
|
.selectAll("g")
|
||||||
|
.select(["e.media_type_override", "w.media_type"])
|
||||||
|
.where("g.id", "=", engagementId)
|
||||||
|
.where("e.user_id", "=", userId);
|
||||||
|
if (!opts.includeDeleted) q = q.where("g.deleted_at", "is", null).where("e.deleted_at", "is", null);
|
||||||
|
const r = await q.executeTakeFirst();
|
||||||
|
if (!r) throw new NotFoundError("engagement");
|
||||||
|
return { eg: toEngagement(r), mediaType: r.media_type_override ?? r.media_type };
|
||||||
|
}
|
||||||
|
|
||||||
|
async function write(
|
||||||
|
k: Executor,
|
||||||
|
userId: string,
|
||||||
|
current: Engagement,
|
||||||
|
mediaType: string,
|
||||||
|
patch: Partial<EngagementPatch>,
|
||||||
|
actor: Actor,
|
||||||
|
extra: Partial<Pick<Row, "deleted_at" | "entry_id">> = {},
|
||||||
|
): Promise<Engagement> {
|
||||||
|
const cols = await toColumns(k, userId, mediaType, patch);
|
||||||
|
const fieldSources = { ...current.fieldSources };
|
||||||
|
for (const key of Object.keys(patch)) fieldSources[key] = actor;
|
||||||
|
await k
|
||||||
|
.updateTable("engagements")
|
||||||
|
.set({ ...cols, ...extra, field_sources: toJson(fieldSources), updated_at: nowIso() })
|
||||||
|
.where("id", "=", current.id)
|
||||||
|
.execute();
|
||||||
|
const { eg } = await load(k, userId, current.id, { includeDeleted: true });
|
||||||
|
await recordHistory(k, {
|
||||||
|
userId,
|
||||||
|
entity: "engagement",
|
||||||
|
entityId: current.id,
|
||||||
|
before: snapshot(current),
|
||||||
|
after: snapshot(eg),
|
||||||
|
actor,
|
||||||
|
});
|
||||||
|
return eg;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Add an engagement to the user's live entry. Each patched key gets `field_sources[key] = actor`. */
|
||||||
|
export async function addEngagement(
|
||||||
|
db: Db,
|
||||||
|
userId: string,
|
||||||
|
entryId: string,
|
||||||
|
patch: Partial<EngagementPatch>,
|
||||||
|
actor: Actor,
|
||||||
|
tx?: Executor,
|
||||||
|
): Promise<Engagement> {
|
||||||
|
return inTx(db, tx, async (k) => {
|
||||||
|
const { mediaType } = await requireEntry(k, userId, entryId);
|
||||||
|
const cols = await toColumns(k, userId, mediaType, patch);
|
||||||
|
const fieldSources: FieldSources = {};
|
||||||
|
for (const key of Object.keys(patch)) fieldSources[key] = actor;
|
||||||
|
const now = nowIso();
|
||||||
|
const row: Row = {
|
||||||
|
id: newId(),
|
||||||
|
entry_id: entryId,
|
||||||
|
started_at: null,
|
||||||
|
started_precision: "unknown",
|
||||||
|
finished_at: null,
|
||||||
|
finished_precision: "unknown",
|
||||||
|
progress: toJson({}),
|
||||||
|
platform: null,
|
||||||
|
rating: null,
|
||||||
|
notes: null,
|
||||||
|
custom_fields: toJson({}),
|
||||||
|
...cols,
|
||||||
|
field_sources: toJson(fieldSources),
|
||||||
|
created_at: now,
|
||||||
|
updated_at: now,
|
||||||
|
deleted_at: null,
|
||||||
|
};
|
||||||
|
await k.insertInto("engagements").values(row).execute();
|
||||||
|
const eg = toEngagement(row);
|
||||||
|
await recordHistory(k, { userId, entity: "engagement", entityId: eg.id, before: null, after: snapshot(eg), actor });
|
||||||
|
return eg;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Patch a live engagement; only the keys present are touched. Writes history. */
|
||||||
|
export async function updateEngagement(
|
||||||
|
db: Db,
|
||||||
|
userId: string,
|
||||||
|
engagementId: string,
|
||||||
|
patch: Partial<EngagementPatch>,
|
||||||
|
actor: Actor,
|
||||||
|
tx?: Executor,
|
||||||
|
): Promise<Engagement> {
|
||||||
|
return inTx(db, tx, async (k) => {
|
||||||
|
const { eg, mediaType } = await load(k, userId, engagementId);
|
||||||
|
if (Object.keys(patch).length === 0) return eg;
|
||||||
|
return write(k, userId, eg, mediaType, patch, actor);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Soft-delete an engagement. Writes history. */
|
||||||
|
export async function softDeleteEngagement(
|
||||||
|
db: Db,
|
||||||
|
userId: string,
|
||||||
|
engagementId: string,
|
||||||
|
actor: Actor,
|
||||||
|
tx?: Executor,
|
||||||
|
): Promise<void> {
|
||||||
|
await inTx(db, tx, async (k) => {
|
||||||
|
const { eg, mediaType } = await load(k, userId, engagementId);
|
||||||
|
await write(k, userId, eg, mediaType, {}, actor, { deleted_at: nowIso() });
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The live engagements of the user's live entry, by start date (undated last), then creation. */
|
||||||
|
export async function listEngagements(db: Db, userId: string, entryId: string): Promise<Engagement[]> {
|
||||||
|
await requireEntry(db.k, userId, entryId);
|
||||||
|
const rows = await db.k
|
||||||
|
.selectFrom("engagements")
|
||||||
|
.selectAll()
|
||||||
|
.where("entry_id", "=", entryId)
|
||||||
|
.where("deleted_at", "is", null)
|
||||||
|
.orderBy(sql`case when started_at is null then 1 else 0 end`)
|
||||||
|
.orderBy("started_at")
|
||||||
|
.orderBy("created_at")
|
||||||
|
.orderBy("id")
|
||||||
|
.execute();
|
||||||
|
return rows.map(toEngagement);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Called by `restoreVersion`: re-apply a history row's `before` values as actor "user". */
|
||||||
|
export async function restoreEngagementFromHistory(
|
||||||
|
k: Executor,
|
||||||
|
userId: string,
|
||||||
|
engagementId: string,
|
||||||
|
before: Record<string, unknown>,
|
||||||
|
): Promise<void> {
|
||||||
|
const { eg, mediaType } = await load(k, userId, engagementId, { includeDeleted: true });
|
||||||
|
const patch: Record<string, unknown> = {};
|
||||||
|
for (const key of ENGAGEMENT_PATCH_KEYS) if (key in before) patch[key] = before[key];
|
||||||
|
const extra: Partial<Pick<Row, "deleted_at" | "entry_id">> =
|
||||||
|
"deletedAt" in before ? { deleted_at: (before.deletedAt as string | null) ?? null } : {};
|
||||||
|
if (typeof before.entryId === "string" && before.entryId !== eg.entryId) {
|
||||||
|
// Undo a merge's re-parenting; the old entry must be this user's.
|
||||||
|
const owner = await k
|
||||||
|
.selectFrom("entries")
|
||||||
|
.select(["id", "deleted_at"])
|
||||||
|
.where("id", "=", before.entryId)
|
||||||
|
.where("user_id", "=", userId)
|
||||||
|
.executeTakeFirst();
|
||||||
|
if (!owner) throw new NotFoundError("entry");
|
||||||
|
if (owner.deleted_at !== null) throw new ValidationError("entryId", "target entry is deleted");
|
||||||
|
extra.entry_id = before.entryId;
|
||||||
|
}
|
||||||
|
await write(k, userId, eg, mediaType, patch as Partial<EngagementPatch>, "user", extra);
|
||||||
|
}
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,42 @@
|
|||||||
|
/** Authentication or authorisation failure; `code` names the reason (e.g. "invalid_credentials"). */
|
||||||
|
export class AuthError extends Error {
|
||||||
|
override readonly name = "AuthError";
|
||||||
|
constructor(readonly code: string) {
|
||||||
|
super(code);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Input rejected by validation. */
|
||||||
|
export class ValidationError extends Error {
|
||||||
|
override readonly name = "ValidationError";
|
||||||
|
readonly code = "validation";
|
||||||
|
constructor(
|
||||||
|
readonly field: string,
|
||||||
|
readonly reason: string,
|
||||||
|
) {
|
||||||
|
super(`${field}: ${reason}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Encryption or decryption failure (e.g. wrong key, tampered ciphertext). */
|
||||||
|
export class CryptoError extends Error {
|
||||||
|
override readonly name = "CryptoError";
|
||||||
|
readonly code = "crypto";
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Import or restore failure; `code` names the reason. */
|
||||||
|
export class ImportError extends Error {
|
||||||
|
override readonly name = "ImportError";
|
||||||
|
constructor(readonly code: string) {
|
||||||
|
super(code);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A requested record does not exist (or is not visible to the caller). */
|
||||||
|
export class NotFoundError extends Error {
|
||||||
|
override readonly name = "NotFoundError";
|
||||||
|
readonly code = "not_found";
|
||||||
|
constructor(readonly what: string) {
|
||||||
|
super(`${what} not found`);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,474 @@
|
|||||||
|
import { strFromU8, strToU8, unzipSync, zipSync } from "fflate";
|
||||||
|
import type { MediaType } from "@sttracker/plugin-sdk/types";
|
||||||
|
import { toCsv } from "@sttracker/plugin-sdk/csv";
|
||||||
|
import type { Db } from "@sttracker/db/db";
|
||||||
|
import { newId } from "@sttracker/db/ids";
|
||||||
|
import { fromJson, toJson } from "@sttracker/db/json";
|
||||||
|
import type { AttachmentEntity, EntryStatus, Visibility } from "@sttracker/db/schema";
|
||||||
|
import { MAX_ATTACHMENT_BYTES, mimeAllowed, normalizeMime, sha256Hex, writeAttachmentFile } from "./attachments.ts";
|
||||||
|
import { isUniqueViolation } from "./matching.ts";
|
||||||
|
import { ImportError } from "./errors.ts";
|
||||||
|
import { createWorkIn, findWorkByExternalIdsIn, type K, type Position } from "./works.ts";
|
||||||
|
|
||||||
|
export const EXPORT_FORMAT = "sttracker-export/v1";
|
||||||
|
|
||||||
|
// Export rows are loosely typed records: the zip is untrusted input on import.
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
type Rec = Record<string, any>;
|
||||||
|
|
||||||
|
const camel = (s: string) => s.replace(/_([a-z0-9])/g, (_, c: string) => c.toUpperCase());
|
||||||
|
const snake = (s: string) => s.replace(/[A-Z]/g, (c) => `_${c.toLowerCase()}`);
|
||||||
|
|
||||||
|
const JSON_COLS = {
|
||||||
|
works: ["creators", "metadata", "position"],
|
||||||
|
entries: ["tags", "custom_fields", "overrides", "field_sources"],
|
||||||
|
engagements: ["progress", "custom_fields", "field_sources"],
|
||||||
|
listens: ["mbids"],
|
||||||
|
copies: ["custom_fields"],
|
||||||
|
defs: ["options", "applies_to"],
|
||||||
|
history: ["before", "after"],
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
/** DB row -> camelCase export record, JSON columns parsed, `drop` columns removed. */
|
||||||
|
function out(row: Rec, jsonCols: readonly string[], drop: readonly string[] = []): Rec {
|
||||||
|
const o: Rec = {};
|
||||||
|
for (const [k, v] of Object.entries(row)) {
|
||||||
|
if (drop.includes(k)) continue;
|
||||||
|
o[camel(k)] = jsonCols.includes(k) ? fromJson(v as string, null) : v;
|
||||||
|
}
|
||||||
|
return o;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** camelCase export record -> snake_case row, JSON columns serialised. */
|
||||||
|
function inn(rec: Rec, jsonCols: readonly string[]): Rec {
|
||||||
|
const o: Rec = {};
|
||||||
|
for (const [k, v] of Object.entries(rec)) {
|
||||||
|
const col = snake(k);
|
||||||
|
o[col] = jsonCols.includes(col) ? toJson(v) : v;
|
||||||
|
}
|
||||||
|
return o;
|
||||||
|
}
|
||||||
|
|
||||||
|
const jsonl = (rows: Rec[]) => strToU8(rows.map((r) => JSON.stringify(r) + "\n").join(""));
|
||||||
|
|
||||||
|
function readJsonl(files: Record<string, Uint8Array>, name: string): Rec[] {
|
||||||
|
const f = files[name];
|
||||||
|
if (!f) return [];
|
||||||
|
return strFromU8(f)
|
||||||
|
.split("\n")
|
||||||
|
.filter((l) => l.trim() !== "")
|
||||||
|
.map((l) => JSON.parse(l) as Rec);
|
||||||
|
}
|
||||||
|
|
||||||
|
const CHUNK = 500;
|
||||||
|
const MAX_UNCOMPRESSED = 2 * 1024 * 1024 * 1024;
|
||||||
|
|
||||||
|
/** Run `fn` over `ids` in batches (keeps `IN (...)` lists under driver parameter limits). */
|
||||||
|
async function inChunks<T>(ids: readonly string[], fn: (chunk: string[]) => Promise<T[]>): Promise<T[]> {
|
||||||
|
const all: T[] = [];
|
||||||
|
for (let i = 0; i < ids.length; i += CHUNK) all.push(...(await fn(ids.slice(i, i + CHUNK))));
|
||||||
|
return all;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Per-user export bundle (zip). Includes soft-deleted rows so a restore is lossless; never credentials.
|
||||||
|
* All reads happen in one transaction (repeatable read on Postgres) so the bundle is a consistent snapshot.
|
||||||
|
*/
|
||||||
|
export function exportUserBundle(db: Db, dataDir: string, userId: string): Promise<Uint8Array> {
|
||||||
|
const tb = db.k.transaction();
|
||||||
|
return (db.dialect === "postgres" ? tb.setIsolationLevel("repeatable read") : tb).execute((tx) => buildBundle(tx, dataDir, userId));
|
||||||
|
}
|
||||||
|
|
||||||
|
async function buildBundle(k: K, dataDir: string, userId: string): Promise<Uint8Array> {
|
||||||
|
const user = await k.selectFrom("users").select(["username", "display_name"]).where("id", "=", userId).executeTakeFirstOrThrow();
|
||||||
|
|
||||||
|
const entries = await k.selectFrom("entries").selectAll().where("user_id", "=", userId).orderBy("id").execute();
|
||||||
|
const engagements = (
|
||||||
|
await k.selectFrom("engagements").innerJoin("entries", "entries.id", "engagements.entry_id").selectAll("engagements").where("entries.user_id", "=", userId).orderBy("engagements.id").execute()
|
||||||
|
);
|
||||||
|
const listens = await k.selectFrom("listens").selectAll().where("user_id", "=", userId).orderBy("id").execute();
|
||||||
|
const lists = await k.selectFrom("lists").selectAll().where("user_id", "=", userId).orderBy("id").execute();
|
||||||
|
const items = await inChunks(lists.map((l) => l.id), (c) =>
|
||||||
|
k.selectFrom("list_items").selectAll().where("list_id", "in", c).orderBy("position").orderBy("id").execute(),
|
||||||
|
);
|
||||||
|
const copies = await k.selectFrom("copies").selectAll().where("user_id", "=", userId).orderBy("id").execute();
|
||||||
|
const defs = await k.selectFrom("custom_field_definitions").selectAll().where("user_id", "=", userId).orderBy("id").execute();
|
||||||
|
const history = await k.selectFrom("user_history").selectAll().where("user_id", "=", userId).orderBy("created_at").orderBy("id").execute();
|
||||||
|
const atts = await k.selectFrom("attachments").selectAll().where("user_id", "=", userId).orderBy("id").execute();
|
||||||
|
const links = await inChunks(atts.map((a) => a.id), (c) => k.selectFrom("attachment_links").selectAll().where("attachment_id", "in", c).execute());
|
||||||
|
const aliases = await k.selectFrom("work_aliases").selectAll().where("user_id", "=", userId).orderBy("id").execute();
|
||||||
|
|
||||||
|
// Works: everything referenced, plus ancestors.
|
||||||
|
const workIds = new Set<string>();
|
||||||
|
for (const e of entries) workIds.add(e.work_id);
|
||||||
|
for (const i of items) workIds.add(i.work_id);
|
||||||
|
for (const c of copies) if (c.work_id) workIds.add(c.work_id);
|
||||||
|
for (const l of listens) if (l.work_id) workIds.add(l.work_id);
|
||||||
|
for (const a of aliases) {
|
||||||
|
workIds.add(a.from_work_id);
|
||||||
|
workIds.add(a.to_work_id);
|
||||||
|
}
|
||||||
|
const works = new Map<string, Rec>();
|
||||||
|
let frontier = [...workIds];
|
||||||
|
while (frontier.length) {
|
||||||
|
const rows = await inChunks(frontier, (c) => k.selectFrom("works").selectAll().where("id", "in", c).execute());
|
||||||
|
frontier = [];
|
||||||
|
for (const w of rows) {
|
||||||
|
works.set(w.id, w as Rec);
|
||||||
|
// Ancestors and merge survivors are part of the bundle too.
|
||||||
|
for (const ref of [w.parent_id, w.merged_into_id]) if (ref && !works.has(ref)) frontier.push(ref);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const extIds = await inChunks([...works.keys()], (c) => k.selectFrom("external_ids").selectAll().where("work_id", "in", c).orderBy("namespace").execute());
|
||||||
|
const extByWork = new Map<string, Record<string, string>>();
|
||||||
|
for (const x of extIds) {
|
||||||
|
const m = extByWork.get(x.work_id) ?? {};
|
||||||
|
m[x.namespace] = x.value;
|
||||||
|
extByWork.set(x.work_id, m);
|
||||||
|
}
|
||||||
|
const workRecs = [...works.values()]
|
||||||
|
.sort((a, b) => (a.id < b.id ? -1 : 1))
|
||||||
|
.map((w) => ({
|
||||||
|
...out(w, JSON_COLS.works, ["owner_user_id", "cover_attachment_id"]),
|
||||||
|
private: w.owner_user_id != null,
|
||||||
|
externalIds: extByWork.get(w.id) ?? {},
|
||||||
|
}));
|
||||||
|
|
||||||
|
const files: Record<string, Uint8Array> = {};
|
||||||
|
const attRecs: Rec[] = [];
|
||||||
|
for (const a of atts) {
|
||||||
|
const file = Bun.file(`${dataDir}/attachments/${a.sha256.slice(0, 2)}/${a.sha256.slice(2, 4)}/${a.sha256}`);
|
||||||
|
if (!(await file.exists())) continue;
|
||||||
|
files[`attachments/${a.sha256}`] = new Uint8Array(await file.arrayBuffer());
|
||||||
|
attRecs.push({
|
||||||
|
...out(a, [], ["user_id"]),
|
||||||
|
links: links.filter((l) => l.attachment_id === a.id).map((l) => ({ entity: l.entity, entityId: l.entity_id })),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const listRecs = lists.map((l) => ({
|
||||||
|
...out(l, [], ["user_id"]),
|
||||||
|
items: items.filter((i) => i.list_id === l.id).map((i) => out(i, [], ["list_id"])),
|
||||||
|
}));
|
||||||
|
|
||||||
|
const manifest = {
|
||||||
|
format: EXPORT_FORMAT,
|
||||||
|
exportedAt: new Date().toISOString(),
|
||||||
|
user: { username: user.username, displayName: user.display_name },
|
||||||
|
};
|
||||||
|
return zipSync({
|
||||||
|
"manifest.json": strToU8(JSON.stringify(manifest, null, 2)),
|
||||||
|
"works.jsonl": jsonl(workRecs),
|
||||||
|
"entries.jsonl": jsonl(entries.map((e) => ({ ...out(e, JSON_COLS.entries, ["user_id"]), favorite: e.favorite !== 0 }))),
|
||||||
|
"engagements.jsonl": jsonl(engagements.map((e) => out(e, JSON_COLS.engagements))),
|
||||||
|
"listens.jsonl": jsonl(listens.map((l) => out(l, JSON_COLS.listens, ["user_id"]))),
|
||||||
|
"lists.jsonl": jsonl(listRecs),
|
||||||
|
"copies.jsonl": jsonl(copies.map((c) => out(c, JSON_COLS.copies, ["user_id"]))),
|
||||||
|
"custom_fields.jsonl": jsonl(defs.map((d) => out(d, JSON_COLS.defs, ["user_id"]))),
|
||||||
|
"work_aliases.jsonl": jsonl(aliases.map((a) => ({ fromWorkId: a.from_work_id, toWorkId: a.to_work_id }))),
|
||||||
|
"history.jsonl": jsonl(history.map((h) => out(h, JSON_COLS.history, ["user_id"]))),
|
||||||
|
"attachments.jsonl": jsonl(attRecs),
|
||||||
|
...files,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** CSV per entity type (live rows only): entries, engagements, listens. */
|
||||||
|
export async function exportUserCsv(db: Db, userId: string): Promise<Record<string, string>> {
|
||||||
|
const k = db.k;
|
||||||
|
const s = (v: unknown) => (v == null ? "" : String(v));
|
||||||
|
const entries = await k
|
||||||
|
.selectFrom("entries")
|
||||||
|
.innerJoin("works", "works.id", "entries.work_id")
|
||||||
|
.select(["entries.id", "entries.work_id", "works.title", "works.media_type", "works.year", "entries.status", "entries.rating", "entries.favorite", "entries.tags", "entries.review", "entries.created_at", "entries.updated_at"])
|
||||||
|
.where("entries.user_id", "=", userId)
|
||||||
|
.where("entries.deleted_at", "is", null)
|
||||||
|
.orderBy("entries.id")
|
||||||
|
.execute();
|
||||||
|
const engagements = await k
|
||||||
|
.selectFrom("engagements")
|
||||||
|
.innerJoin("entries", "entries.id", "engagements.entry_id")
|
||||||
|
.innerJoin("works", "works.id", "entries.work_id")
|
||||||
|
.select(["engagements.id", "engagements.entry_id", "works.title", "engagements.started_at", "engagements.started_precision", "engagements.finished_at", "engagements.finished_precision", "engagements.platform", "engagements.rating", "engagements.notes"])
|
||||||
|
.where("entries.user_id", "=", userId)
|
||||||
|
.where("entries.deleted_at", "is", null)
|
||||||
|
.where("engagements.deleted_at", "is", null)
|
||||||
|
.orderBy("engagements.id")
|
||||||
|
.execute();
|
||||||
|
const listens = await k.selectFrom("listens").selectAll().where("user_id", "=", userId).orderBy("listened_at").orderBy("id").execute();
|
||||||
|
|
||||||
|
return {
|
||||||
|
"entries.csv": toCsv([
|
||||||
|
["id", "workId", "title", "mediaType", "year", "status", "rating", "favorite", "tags", "review", "createdAt", "updatedAt"],
|
||||||
|
...entries.map((e) => [e.id, e.work_id, e.title, e.media_type, s(e.year), e.status, s(e.rating), e.favorite ? "true" : "false", fromJson<string[]>(e.tags, []).join("; "), s(e.review), e.created_at, e.updated_at]),
|
||||||
|
]),
|
||||||
|
"engagements.csv": toCsv([
|
||||||
|
["id", "entryId", "title", "startedAt", "startedPrecision", "finishedAt", "finishedPrecision", "platform", "rating", "notes"],
|
||||||
|
...engagements.map((e) => [e.id, e.entry_id, e.title, s(e.started_at), e.started_precision, s(e.finished_at), e.finished_precision, s(e.platform), s(e.rating), s(e.notes)]),
|
||||||
|
]),
|
||||||
|
"listens.csv": toCsv([
|
||||||
|
["id", "listenedAt", "artist", "track", "album", "durationMs", "source"],
|
||||||
|
...listens.map((l) => [l.id, l.listened_at, l.artist, l.track, s(l.album), s(l.duration_ms), l.source]),
|
||||||
|
]),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const UUID_RE = /[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}/g;
|
||||||
|
const STATUSES: readonly EntryStatus[] = ["planned", "in_progress", "paused", "completed", "dropped", "none"];
|
||||||
|
const VISIBILITIES: readonly Visibility[] = ["private", "friends", "public"];
|
||||||
|
|
||||||
|
function corrupt(): never {
|
||||||
|
throw new ImportError("corrupt_bundle");
|
||||||
|
}
|
||||||
|
|
||||||
|
function checkRatingValue(v: unknown): void {
|
||||||
|
if (v != null && !(typeof v === "number" && Number.isInteger(v) && v >= 0 && v <= 100)) corrupt();
|
||||||
|
}
|
||||||
|
|
||||||
|
function checkRows(rows: Rec[], opts: { status?: boolean; visibility?: boolean; rating?: boolean }): void {
|
||||||
|
for (const r of rows) {
|
||||||
|
if (opts.rating) checkRatingValue(r.rating);
|
||||||
|
if (opts.status && !STATUSES.includes(r.status)) corrupt();
|
||||||
|
if (opts.visibility && !VISIBILITIES.includes(r.visibility)) corrupt();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Restore a bundle into an account with no entries. Works are re-matched by external id. Everything is
|
||||||
|
* validated before anything is written; rows (attachments included) are inserted in one transaction.
|
||||||
|
*/
|
||||||
|
export async function importUserBundle(
|
||||||
|
db: Db,
|
||||||
|
dataDir: string,
|
||||||
|
userId: string,
|
||||||
|
zip: Uint8Array,
|
||||||
|
): Promise<{ works: number; entries: number }> {
|
||||||
|
let files: Record<string, Uint8Array>;
|
||||||
|
let total = 0;
|
||||||
|
try {
|
||||||
|
files = unzipSync(zip, {
|
||||||
|
filter: (f) => {
|
||||||
|
total += f.originalSize;
|
||||||
|
if (total > MAX_UNCOMPRESSED) throw new ImportError("bundle_too_large");
|
||||||
|
return true;
|
||||||
|
},
|
||||||
|
});
|
||||||
|
} catch (e) {
|
||||||
|
if (e instanceof ImportError) throw e;
|
||||||
|
throw new ImportError("unsupported_format");
|
||||||
|
}
|
||||||
|
const mf = files["manifest.json"];
|
||||||
|
let manifest: Rec = {};
|
||||||
|
try {
|
||||||
|
manifest = mf ? (JSON.parse(strFromU8(mf)) as Rec) : {};
|
||||||
|
} catch {
|
||||||
|
/* handled below */
|
||||||
|
}
|
||||||
|
if (manifest.format !== EXPORT_FORMAT) throw new ImportError("unsupported_format");
|
||||||
|
|
||||||
|
const existing = await db.k.selectFrom("entries").select("id").where("user_id", "=", userId).limit(1).executeTakeFirst();
|
||||||
|
if (existing) throw new ImportError("account_not_empty");
|
||||||
|
|
||||||
|
let works: Rec[], entries: Rec[], engagements: Rec[], listens: Rec[], lists: Rec[], copies: Rec[];
|
||||||
|
let defs: Rec[], history: Rec[], atts: Rec[], aliases: Rec[];
|
||||||
|
try {
|
||||||
|
works = readJsonl(files, "works.jsonl");
|
||||||
|
entries = readJsonl(files, "entries.jsonl");
|
||||||
|
engagements = readJsonl(files, "engagements.jsonl");
|
||||||
|
listens = readJsonl(files, "listens.jsonl");
|
||||||
|
lists = readJsonl(files, "lists.jsonl");
|
||||||
|
copies = readJsonl(files, "copies.jsonl");
|
||||||
|
defs = readJsonl(files, "custom_fields.jsonl");
|
||||||
|
history = readJsonl(files, "history.jsonl");
|
||||||
|
atts = readJsonl(files, "attachments.jsonl");
|
||||||
|
aliases = readJsonl(files, "work_aliases.jsonl");
|
||||||
|
} catch {
|
||||||
|
return corrupt();
|
||||||
|
}
|
||||||
|
checkRows(entries, { rating: true, status: true, visibility: true });
|
||||||
|
checkRows(engagements, { rating: true });
|
||||||
|
checkRows(lists, { rating: true, visibility: true });
|
||||||
|
|
||||||
|
// Attachments: validate everything before writing anything.
|
||||||
|
for (const a of atts) {
|
||||||
|
const bytes = files[`attachments/${a.sha256}`];
|
||||||
|
if (
|
||||||
|
!bytes ||
|
||||||
|
!mimeAllowed(normalizeMime(String(a.mime ?? ""))) ||
|
||||||
|
bytes.byteLength > MAX_ATTACHMENT_BYTES ||
|
||||||
|
sha256Hex(bytes) !== a.sha256
|
||||||
|
) {
|
||||||
|
throw new ImportError("invalid_attachment");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for (const a of atts) await writeAttachmentFile(dataDir, files[`attachments/${a.sha256}`]!);
|
||||||
|
|
||||||
|
const ids = new Map<string, string>();
|
||||||
|
const remap = (old: unknown): string | null => (typeof old === "string" ? (ids.get(old) ?? null) : null);
|
||||||
|
const remapCustom = (v: unknown): Rec => {
|
||||||
|
const o: Rec = {};
|
||||||
|
for (const [key, val] of Object.entries((v ?? {}) as Rec)) o[ids.get(key) ?? key] = val;
|
||||||
|
return o;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Parents before children.
|
||||||
|
const byId = new Map(works.map((w) => [w.id as string, w]));
|
||||||
|
const depth = (w: Rec): number => {
|
||||||
|
let d = 0;
|
||||||
|
const seen = new Set<string>();
|
||||||
|
for (let p = w.parentId ? byId.get(w.parentId) : undefined; p && !seen.has(p.id); p = p.parentId ? byId.get(p.parentId) : undefined) {
|
||||||
|
seen.add(p.id);
|
||||||
|
d++;
|
||||||
|
}
|
||||||
|
return d;
|
||||||
|
};
|
||||||
|
const ordered = works.map((w) => ({ w, d: depth(w) })).sort((a, b) => a.d - b.d).map((x) => x.w);
|
||||||
|
|
||||||
|
try {
|
||||||
|
await db.k.transaction().execute(async (tx) => {
|
||||||
|
const created = new Set<string>();
|
||||||
|
for (const w of ordered) {
|
||||||
|
const externalIds = (w.externalIds ?? {}) as Record<string, string>;
|
||||||
|
const hasExt = Object.keys(externalIds).length > 0;
|
||||||
|
const matched = hasExt ? await findWorkByExternalIdsIn(tx, externalIds, userId) : null;
|
||||||
|
if (matched) {
|
||||||
|
ids.set(w.id, matched);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const newWorkId = await createWorkIn(
|
||||||
|
tx,
|
||||||
|
{
|
||||||
|
hint: {
|
||||||
|
title: w.title,
|
||||||
|
mediaType: w.mediaType as MediaType,
|
||||||
|
year: w.year ?? undefined,
|
||||||
|
creators: w.creators ?? [],
|
||||||
|
externalIds,
|
||||||
|
coverUrl: w.coverUrl ?? "",
|
||||||
|
description: w.description ?? "",
|
||||||
|
position: (w.position ?? undefined) as Position | undefined,
|
||||||
|
},
|
||||||
|
ownerUserId: hasExt ? null : userId,
|
||||||
|
parentId: remap(w.parentId),
|
||||||
|
},
|
||||||
|
"import",
|
||||||
|
);
|
||||||
|
await tx
|
||||||
|
.updateTable("works")
|
||||||
|
.set({
|
||||||
|
original_title: w.originalTitle ?? "",
|
||||||
|
release_date: w.releaseDate ?? null,
|
||||||
|
metadata: toJson(w.metadata ?? {}),
|
||||||
|
created_at: w.createdAt,
|
||||||
|
updated_at: w.updatedAt,
|
||||||
|
})
|
||||||
|
.where("id", "=", newWorkId)
|
||||||
|
.execute();
|
||||||
|
ids.set(w.id, newWorkId);
|
||||||
|
created.add(w.id);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Tombstones: only for works created here (a matched existing work keeps its own state).
|
||||||
|
for (const w of works) {
|
||||||
|
const target = remap(w.mergedIntoId);
|
||||||
|
if (!created.has(w.id) || !target) continue;
|
||||||
|
await tx.updateTable("works").set({ merged_into_id: target }).where("id", "=", ids.get(w.id)!).execute();
|
||||||
|
}
|
||||||
|
for (const a of aliases) {
|
||||||
|
const from = remap(a.fromWorkId);
|
||||||
|
const to = remap(a.toWorkId);
|
||||||
|
if (!from || !to || from === to) continue;
|
||||||
|
await tx.insertInto("work_aliases").values({ id: newId(), user_id: userId, from_work_id: from, to_work_id: to }).execute();
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const d of defs) {
|
||||||
|
const id = newId();
|
||||||
|
ids.set(d.id, id);
|
||||||
|
await tx.insertInto("custom_field_definitions").values({ ...inn(d, JSON_COLS.defs), id, user_id: userId } as never).execute();
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const e of entries) {
|
||||||
|
const id = newId();
|
||||||
|
ids.set(e.id, id);
|
||||||
|
const workId = remap(e.workId);
|
||||||
|
if (!workId) corrupt();
|
||||||
|
await tx
|
||||||
|
.insertInto("entries")
|
||||||
|
.values({ ...inn(e, JSON_COLS.entries), id, user_id: userId, work_id: workId, favorite: e.favorite ? 1 : 0, custom_fields: toJson(remapCustom(e.customFields)) } as never)
|
||||||
|
.execute();
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const g of engagements) {
|
||||||
|
const id = newId();
|
||||||
|
ids.set(g.id, id);
|
||||||
|
const entryId = remap(g.entryId);
|
||||||
|
if (!entryId) corrupt();
|
||||||
|
await tx
|
||||||
|
.insertInto("engagements")
|
||||||
|
.values({ ...inn(g, JSON_COLS.engagements), id, entry_id: entryId, custom_fields: toJson(remapCustom(g.customFields)) } as never)
|
||||||
|
.execute();
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const l of listens) {
|
||||||
|
await tx
|
||||||
|
.insertInto("listens")
|
||||||
|
.values({ ...inn(l, JSON_COLS.listens), id: newId(), user_id: userId, work_id: remap(l.workId) } as never)
|
||||||
|
.onConflict((oc) => oc.doNothing())
|
||||||
|
.execute();
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const l of lists) {
|
||||||
|
const id = newId();
|
||||||
|
ids.set(l.id, id);
|
||||||
|
const { items, ...rest } = l;
|
||||||
|
await tx.insertInto("lists").values({ ...inn(rest, []), id, user_id: userId } as never).execute();
|
||||||
|
for (const it of (items ?? []) as Rec[]) {
|
||||||
|
const workId = remap(it.workId);
|
||||||
|
if (!workId) continue;
|
||||||
|
await tx.insertInto("list_items").values({ ...inn(it, []), id: newId(), list_id: id, work_id: workId } as never).execute();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const c of copies) {
|
||||||
|
const id = newId();
|
||||||
|
ids.set(c.id, id);
|
||||||
|
await tx
|
||||||
|
.insertInto("copies")
|
||||||
|
.values({ ...inn(c, JSON_COLS.copies), id, user_id: userId, work_id: remap(c.workId), custom_fields: toJson(remapCustom(c.customFields)) } as never)
|
||||||
|
.execute();
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const a of atts) {
|
||||||
|
const links = ((a.links ?? []) as Rec[])
|
||||||
|
.map((l) => ({ entity: l.entity as AttachmentEntity, entityId: remap(l.entityId) }))
|
||||||
|
.filter((l): l is { entity: AttachmentEntity; entityId: string } => l.entityId != null);
|
||||||
|
if (links.length === 0) continue;
|
||||||
|
const id = newId();
|
||||||
|
const { links: _links, ...attRow } = a;
|
||||||
|
await tx
|
||||||
|
.insertInto("attachments")
|
||||||
|
.values({ ...inn(attRow, []), id, user_id: userId, mime: normalizeMime(a.mime), size: files[`attachments/${a.sha256}`]!.byteLength } as never)
|
||||||
|
.execute();
|
||||||
|
for (const l of links) {
|
||||||
|
await tx.insertInto("attachment_links").values({ id: newId(), attachment_id: id, entity: l.entity, entity_id: l.entityId }).execute();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// History last, so every referenced id has been remapped.
|
||||||
|
for (const h of history) {
|
||||||
|
const sub = (v: unknown) => toJson(JSON.parse(JSON.stringify(v).replace(UUID_RE, (m) => ids.get(m) ?? m)));
|
||||||
|
await tx
|
||||||
|
.insertInto("user_history")
|
||||||
|
.values({ ...inn(h, []), id: newId(), user_id: userId, entity_id: remap(h.entityId) ?? h.entityId, before: sub(h.before), after: sub(h.after) } as never)
|
||||||
|
.execute();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
} catch (e) {
|
||||||
|
if (e instanceof ImportError) throw e;
|
||||||
|
if (isUniqueViolation(e)) throw new ImportError("conflict");
|
||||||
|
throw e;
|
||||||
|
}
|
||||||
|
// An unlink of identical bytes between the pre-write and the commit may have removed a file.
|
||||||
|
for (const a of atts) await writeAttachmentFile(dataDir, files[`attachments/${a.sha256}`]!);
|
||||||
|
|
||||||
|
return { works: works.length, entries: entries.length };
|
||||||
|
}
|
||||||
@@ -0,0 +1,136 @@
|
|||||||
|
import type { Kysely } from "kysely";
|
||||||
|
import type { Db } from "@sttracker/db/db";
|
||||||
|
import { newId, nowIso } from "@sttracker/db/ids";
|
||||||
|
import { fromJson, toJson } from "@sttracker/db/json";
|
||||||
|
import type { Database } from "@sttracker/db/schema";
|
||||||
|
import { restoreCopyFromHistory } from "./copies.ts";
|
||||||
|
import { restoreEngagementFromHistory } from "./engagements.ts";
|
||||||
|
import { restoreEntryFromHistory } from "./entries.ts";
|
||||||
|
import { NotFoundError } from "./errors.ts";
|
||||||
|
import { restoreListFromHistory } from "./lists.ts";
|
||||||
|
|
||||||
|
/** Who made a change. Plugin actors are `plugin:<pluginId>:<connectionId>`; API tokens are `token:<name>`. */
|
||||||
|
export type Actor = "user" | `plugin:${string}:${string}` | `token:${string}`;
|
||||||
|
|
||||||
|
/** User-layer entities that write `user_history`. */
|
||||||
|
export type HistoryEntity = "entry" | "engagement" | "copy" | "list";
|
||||||
|
|
||||||
|
/** A Kysely handle: the root instance or a transaction. */
|
||||||
|
export type Executor = Kysely<Database>;
|
||||||
|
|
||||||
|
export interface HistoryRecord {
|
||||||
|
id: string;
|
||||||
|
entity: string;
|
||||||
|
entityId: string;
|
||||||
|
before: Record<string, unknown>;
|
||||||
|
after: Record<string, unknown>;
|
||||||
|
actor: string;
|
||||||
|
createdAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Run `fn` in the caller's transaction when given, otherwise in a new one.
|
||||||
|
* Inside `fn` only the passed handle may be used (SQLite deadlocks on `db.k` mid-transaction).
|
||||||
|
*/
|
||||||
|
export function inTx<T>(db: Db, tx: Executor | undefined, fn: (tx: Executor) => Promise<T>): Promise<T> {
|
||||||
|
return tx ? fn(tx) : db.k.transaction().execute((t) => fn(t));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Write a `user_history` row holding only the keys whose values differ between `before` and `after`
|
||||||
|
* (compared as JSON). `null` before/after means the entity did not exist / no longer exists.
|
||||||
|
* Returns the history id, or null when nothing changed (no row written).
|
||||||
|
*/
|
||||||
|
export async function recordHistory(
|
||||||
|
tx: Executor | Db,
|
||||||
|
input: {
|
||||||
|
userId: string;
|
||||||
|
entity: HistoryEntity;
|
||||||
|
entityId: string;
|
||||||
|
before: Record<string, unknown> | null;
|
||||||
|
after: Record<string, unknown> | null;
|
||||||
|
actor: Actor;
|
||||||
|
},
|
||||||
|
): Promise<string | null> {
|
||||||
|
const k = "k" in tx ? tx.k : tx;
|
||||||
|
const before = input.before ?? {};
|
||||||
|
const after = input.after ?? {};
|
||||||
|
const b: Record<string, unknown> = {};
|
||||||
|
const a: Record<string, unknown> = {};
|
||||||
|
for (const key of new Set([...Object.keys(before), ...Object.keys(after)])) {
|
||||||
|
if (toJson(before[key]) === toJson(after[key])) continue;
|
||||||
|
if (key in before) b[key] = before[key];
|
||||||
|
if (key in after) a[key] = after[key];
|
||||||
|
}
|
||||||
|
if (Object.keys(a).length === 0 && Object.keys(b).length === 0) return null;
|
||||||
|
const id = newId();
|
||||||
|
await k
|
||||||
|
.insertInto("user_history")
|
||||||
|
.values({
|
||||||
|
id,
|
||||||
|
user_id: input.userId,
|
||||||
|
entity: input.entity,
|
||||||
|
entity_id: input.entityId,
|
||||||
|
before: toJson(b),
|
||||||
|
after: toJson(a),
|
||||||
|
actor: input.actor,
|
||||||
|
created_at: nowIso(),
|
||||||
|
})
|
||||||
|
.execute();
|
||||||
|
return id;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The user's history for one entity, newest first. Another user's entity yields `[]`. */
|
||||||
|
export async function getHistory(db: Db, userId: string, entity: string, entityId: string): Promise<HistoryRecord[]> {
|
||||||
|
const rows = await db.k
|
||||||
|
.selectFrom("user_history")
|
||||||
|
.selectAll()
|
||||||
|
.where("user_id", "=", userId)
|
||||||
|
.where("entity", "=", entity)
|
||||||
|
.where("entity_id", "=", entityId)
|
||||||
|
.orderBy("created_at", "desc")
|
||||||
|
.orderBy("id", "desc")
|
||||||
|
.execute();
|
||||||
|
return rows.map(toRecord);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Re-apply a history row's `before` values as actor `user` (which writes a new history row and sets
|
||||||
|
* `field_sources` for the restored fields to `"user"`). Throws `NotFoundError` for another user's row.
|
||||||
|
*/
|
||||||
|
export async function restoreVersion(db: Db, userId: string, historyId: string): Promise<void> {
|
||||||
|
const row = await db.k
|
||||||
|
.selectFrom("user_history")
|
||||||
|
.selectAll()
|
||||||
|
.where("id", "=", historyId)
|
||||||
|
.where("user_id", "=", userId)
|
||||||
|
.executeTakeFirst();
|
||||||
|
if (!row) throw new NotFoundError("history");
|
||||||
|
const h = toRecord(row);
|
||||||
|
await db.k.transaction().execute(async (tx) => {
|
||||||
|
switch (h.entity as HistoryEntity) {
|
||||||
|
case "entry":
|
||||||
|
return restoreEntryFromHistory(tx, userId, h.entityId, h.before);
|
||||||
|
case "engagement":
|
||||||
|
return restoreEngagementFromHistory(tx, userId, h.entityId, h.before);
|
||||||
|
case "copy":
|
||||||
|
return restoreCopyFromHistory(tx, userId, h.entityId, h.before);
|
||||||
|
case "list":
|
||||||
|
return restoreListFromHistory(tx, userId, h.entityId, h.before);
|
||||||
|
default:
|
||||||
|
throw new NotFoundError("history");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function toRecord(r: Database["user_history"]): HistoryRecord {
|
||||||
|
return {
|
||||||
|
id: r.id,
|
||||||
|
entity: r.entity,
|
||||||
|
entityId: r.entity_id,
|
||||||
|
before: fromJson<Record<string, unknown>>(r.before, {}),
|
||||||
|
after: fromJson<Record<string, unknown>>(r.after, {}),
|
||||||
|
actor: r.actor,
|
||||||
|
createdAt: r.created_at,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,221 @@
|
|||||||
|
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";
|
||||||
|
|
||||||
|
const MAX_ATTEMPTS = 5;
|
||||||
|
|
||||||
|
const STALE_LOCK_MS = 60 * 60 * 1000;
|
||||||
|
const HEARTBEAT_MS = 5 * 60 * 1000;
|
||||||
|
|
||||||
|
export interface JobContext {
|
||||||
|
/** Aborted when the worker is stopped. */
|
||||||
|
signal: AbortSignal;
|
||||||
|
}
|
||||||
|
export type JobHandler = (payload: any, ctx: JobContext) => Promise<unknown>;
|
||||||
|
|
||||||
|
/** Throw from a handler to fail the job at once, with no retry. */
|
||||||
|
export class NonRetryableError extends Error {
|
||||||
|
override readonly name = "NonRetryableError";
|
||||||
|
}
|
||||||
|
/** Throw from a handler to drop the job: it is marked `done` and the message is kept in `last_error`. */
|
||||||
|
export class DropJobError extends Error {
|
||||||
|
override readonly name = "DropJobError";
|
||||||
|
}
|
||||||
|
|
||||||
|
const logErr = (err: unknown) => console.error("[jobs]", err);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Queue a job. Returns its id, or null when a `queued|running` job already holds `lockKey`.
|
||||||
|
* On Postgres the check and insert run under an advisory transaction lock so two callers cannot both pass.
|
||||||
|
*/
|
||||||
|
export async function enqueue(
|
||||||
|
db: Db,
|
||||||
|
type: string,
|
||||||
|
payload: unknown,
|
||||||
|
opts: { runAt?: string; lockKey?: string } = {},
|
||||||
|
): Promise<string | null> {
|
||||||
|
const lockKey = opts.lockKey ?? null;
|
||||||
|
return db.k.transaction().execute(async (tx) => {
|
||||||
|
if (lockKey !== null) {
|
||||||
|
if (db.dialect === "postgres") await sql`select pg_advisory_xact_lock(hashtext(${lockKey}))`.execute(tx);
|
||||||
|
const held = await tx
|
||||||
|
.selectFrom("jobs")
|
||||||
|
.select("id")
|
||||||
|
.where("lock_key", "=", lockKey)
|
||||||
|
.where("status", "in", ["queued", "running"])
|
||||||
|
.executeTakeFirst();
|
||||||
|
if (held) return null;
|
||||||
|
}
|
||||||
|
const id = newId();
|
||||||
|
const now = nowIso();
|
||||||
|
await tx
|
||||||
|
.insertInto("jobs")
|
||||||
|
.values({
|
||||||
|
id,
|
||||||
|
type,
|
||||||
|
payload: toJson(payload ?? null),
|
||||||
|
run_at: opts.runAt ?? now,
|
||||||
|
status: "queued",
|
||||||
|
attempts: 0,
|
||||||
|
last_error: null,
|
||||||
|
locked_at: null,
|
||||||
|
lock_key: lockKey,
|
||||||
|
created_at: now,
|
||||||
|
})
|
||||||
|
.execute();
|
||||||
|
return id;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Claim one queued job atomically; true when this caller won it. */
|
||||||
|
async function claim(db: Db, id: string): Promise<boolean> {
|
||||||
|
const r = await db.k
|
||||||
|
.updateTable("jobs")
|
||||||
|
.set({ status: "running", locked_at: nowIso() })
|
||||||
|
.where("id", "=", id)
|
||||||
|
.where("status", "=", "queued")
|
||||||
|
.executeTakeFirst();
|
||||||
|
return r.numUpdatedRows === 1n;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function runJob(db: Db, handlers: Record<string, JobHandler>, job: { id: string; type: string; payload: string; attempts: number }, signal: AbortSignal, heartbeatMs: number): Promise<void> {
|
||||||
|
// Keep `locked_at` fresh so another process's reaper does not steal a long-running job.
|
||||||
|
const beat = setInterval(() => {
|
||||||
|
db.k.updateTable("jobs").set({ locked_at: nowIso() }).where("id", "=", job.id).where("status", "=", "running").execute().catch(logErr);
|
||||||
|
}, heartbeatMs);
|
||||||
|
try {
|
||||||
|
try {
|
||||||
|
const h = handlers[job.type];
|
||||||
|
if (!h) throw new NonRetryableError(`no handler for job type "${job.type}"`);
|
||||||
|
await h(fromJson<unknown>(job.payload, null), { signal });
|
||||||
|
await db.k.updateTable("jobs").set({ status: "done", locked_at: null, last_error: null }).where("id", "=", job.id).execute();
|
||||||
|
} catch (e) {
|
||||||
|
const attempts = job.attempts + 1;
|
||||||
|
const err = e instanceof Error ? e.message : String(e);
|
||||||
|
if (signal.aborted && !(e instanceof DropJobError) && !(e instanceof NonRetryableError)) {
|
||||||
|
// Interrupted by shutdown: back to the queue untouched, no attempt burned, no backoff.
|
||||||
|
await db.k.updateTable("jobs").set({ status: "queued", locked_at: null }).where("id", "=", job.id).execute();
|
||||||
|
} else if (e instanceof DropJobError) {
|
||||||
|
await db.k.updateTable("jobs").set({ status: "done", locked_at: null, last_error: err }).where("id", "=", job.id).execute();
|
||||||
|
} else if (e instanceof NonRetryableError || attempts >= MAX_ATTEMPTS) {
|
||||||
|
await db.k.updateTable("jobs").set({ status: "failed", attempts, last_error: err, locked_at: null }).where("id", "=", job.id).execute();
|
||||||
|
} else {
|
||||||
|
const runAt = new Date(Date.now() + 2 ** attempts * 60_000).toISOString();
|
||||||
|
await db.k
|
||||||
|
.updateTable("jobs")
|
||||||
|
.set({ status: "queued", attempts, last_error: err, run_at: runAt, locked_at: null })
|
||||||
|
.where("id", "=", job.id)
|
||||||
|
.execute();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
logErr(e);
|
||||||
|
} finally {
|
||||||
|
clearInterval(beat);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Re-queue jobs a crashed process left `running`, so their lockKey cannot be held forever. */
|
||||||
|
async function reapStale(db: Db, staleMs: number, mine: Set<string>): Promise<void> {
|
||||||
|
const cutoff = new Date(Date.now() - staleMs).toISOString();
|
||||||
|
let q = db.k.updateTable("jobs").set({ status: "queued", locked_at: null }).where("status", "=", "running").where("locked_at", "<", cutoff);
|
||||||
|
if (mine.size > 0) q = q.where("id", "not in", [...mine]);
|
||||||
|
await q.execute();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Poll for due jobs and run them with bounded concurrency. Failures retry with backoff `2^attempts` minutes, then `failed`. */
|
||||||
|
export function startWorker(
|
||||||
|
db: Db,
|
||||||
|
handlers: Record<string, JobHandler>,
|
||||||
|
opts: { pollMs?: number; concurrency?: number; staleMs?: number; heartbeatMs?: number } = {},
|
||||||
|
): { stop(): Promise<void> } {
|
||||||
|
const pollMs = opts.pollMs ?? 1000;
|
||||||
|
const concurrency = opts.concurrency ?? 2;
|
||||||
|
const staleMs = opts.staleMs ?? STALE_LOCK_MS;
|
||||||
|
const heartbeatMs = opts.heartbeatMs ?? HEARTBEAT_MS;
|
||||||
|
const inflight = new Set<Promise<void>>();
|
||||||
|
const mine = new Set<string>();
|
||||||
|
const abort = new AbortController();
|
||||||
|
let stopped = false;
|
||||||
|
let timer: ReturnType<typeof setTimeout> | undefined;
|
||||||
|
let tickP: Promise<void> = Promise.resolve();
|
||||||
|
|
||||||
|
async function tick(): Promise<void> {
|
||||||
|
await reapStale(db, staleMs, mine);
|
||||||
|
while (!stopped && inflight.size < concurrency) {
|
||||||
|
const due = await db.k
|
||||||
|
.selectFrom("jobs")
|
||||||
|
.select(["id", "type", "payload", "attempts"])
|
||||||
|
.where("status", "=", "queued")
|
||||||
|
.where("run_at", "<=", nowIso())
|
||||||
|
.orderBy("run_at")
|
||||||
|
.limit(concurrency - inflight.size)
|
||||||
|
.execute();
|
||||||
|
let started = 0;
|
||||||
|
for (const job of due) {
|
||||||
|
if (stopped || inflight.size >= concurrency) break;
|
||||||
|
if (!(await claim(db, job.id))) continue;
|
||||||
|
started++;
|
||||||
|
mine.add(job.id);
|
||||||
|
const p: Promise<void> = runJob(db, handlers, job, abort.signal, heartbeatMs)
|
||||||
|
.catch(logErr)
|
||||||
|
.finally(() => {
|
||||||
|
inflight.delete(p);
|
||||||
|
mine.delete(job.id);
|
||||||
|
});
|
||||||
|
inflight.add(p);
|
||||||
|
}
|
||||||
|
if (started === 0) break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function schedule() {
|
||||||
|
if (stopped) return;
|
||||||
|
timer = setTimeout(() => {
|
||||||
|
tickP = tick().catch(logErr).finally(schedule);
|
||||||
|
}, pollMs);
|
||||||
|
}
|
||||||
|
tickP = tick().catch(logErr).finally(schedule);
|
||||||
|
|
||||||
|
return {
|
||||||
|
async stop() {
|
||||||
|
stopped = true;
|
||||||
|
abort.abort();
|
||||||
|
if (timer) clearTimeout(timer);
|
||||||
|
await tickP.catch(logErr);
|
||||||
|
await Promise.allSettled([...inflight]);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Every `tickMs`, enqueue a `sync` job for each non-paused (active or errored) connection whose schedule has come due. */
|
||||||
|
export function startScheduler(db: Db, opts: { tickMs?: number } = {}): { stop(): void; tickNow(): Promise<void> } {
|
||||||
|
const tickMs = opts.tickMs ?? 60_000;
|
||||||
|
let stopped = false;
|
||||||
|
async function tick() {
|
||||||
|
const rows = await db.k
|
||||||
|
.selectFrom("source_connections")
|
||||||
|
.select(["id", "schedule_minutes", "last_run_at"])
|
||||||
|
.where("status", "!=", "paused")
|
||||||
|
.where("schedule_minutes", "is not", null)
|
||||||
|
.execute();
|
||||||
|
const now = Date.now();
|
||||||
|
for (const r of rows) {
|
||||||
|
if (stopped) return;
|
||||||
|
const due = r.last_run_at === null || Date.parse(r.last_run_at) + r.schedule_minutes! * 60_000 <= now;
|
||||||
|
if (due) await enqueue(db, "sync", { connectionId: r.id }, { lockKey: `sync:${r.id}` });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const run = () => tick().catch(logErr);
|
||||||
|
void run();
|
||||||
|
const timer = setInterval(() => void run(), tickMs);
|
||||||
|
return {
|
||||||
|
/** Run one scheduling pass now (tests, manual triggers). */
|
||||||
|
tickNow: run,
|
||||||
|
stop() {
|
||||||
|
stopped = true;
|
||||||
|
clearInterval(timer);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,406 @@
|
|||||||
|
import type { Db } from "@sttracker/db/db";
|
||||||
|
import { newId, nowIso } from "@sttracker/db/ids";
|
||||||
|
import type { Database, ListKind, Visibility } from "@sttracker/db/schema";
|
||||||
|
import { checkKeys, checkRating, checkText, defaultVisibility, resolveVisibleWork, VISIBILITIES } from "./entries.ts";
|
||||||
|
import { NotFoundError, ValidationError } from "./errors.ts";
|
||||||
|
import { type Actor, type Executor, inTx, recordHistory } from "./history.ts";
|
||||||
|
|
||||||
|
const KINDS: readonly ListKind[] = ["list", "grouping", "thread"];
|
||||||
|
|
||||||
|
/** Spec §6.5. Only a `grouping` may carry `rating`/`review`. */
|
||||||
|
export interface ListPatch {
|
||||||
|
name: string;
|
||||||
|
description: string;
|
||||||
|
kind: ListKind;
|
||||||
|
visibility: Visibility;
|
||||||
|
rating: number | null;
|
||||||
|
review: string | null;
|
||||||
|
}
|
||||||
|
const LIST_KEYS = ["name", "description", "kind", "visibility", "rating", "review"] as const satisfies readonly (keyof ListPatch)[];
|
||||||
|
|
||||||
|
export interface List extends ListPatch {
|
||||||
|
id: string;
|
||||||
|
userId: string;
|
||||||
|
createdAt: string;
|
||||||
|
updatedAt: string;
|
||||||
|
deletedAt: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ListItem {
|
||||||
|
id: string;
|
||||||
|
listId: string;
|
||||||
|
workId: string;
|
||||||
|
/** 0-based, dense. */
|
||||||
|
position: number;
|
||||||
|
note: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ListWithItems extends List {
|
||||||
|
items: (ListItem & { work: { id: string; title: string; mediaType: string; year: number | null; coverUrl: string } })[];
|
||||||
|
}
|
||||||
|
|
||||||
|
type Row = Database["lists"];
|
||||||
|
type ItemRow = Database["list_items"];
|
||||||
|
|
||||||
|
function toList(r: Row): List {
|
||||||
|
return {
|
||||||
|
id: r.id,
|
||||||
|
userId: r.user_id,
|
||||||
|
name: r.name,
|
||||||
|
description: r.description,
|
||||||
|
kind: r.kind,
|
||||||
|
visibility: r.visibility,
|
||||||
|
rating: r.rating,
|
||||||
|
review: r.review,
|
||||||
|
createdAt: r.created_at,
|
||||||
|
updatedAt: r.updated_at,
|
||||||
|
deletedAt: r.deleted_at,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const toItem = (r: ItemRow): ListItem => ({
|
||||||
|
id: r.id,
|
||||||
|
listId: r.list_id,
|
||||||
|
workId: r.work_id,
|
||||||
|
position: r.position,
|
||||||
|
note: r.note,
|
||||||
|
});
|
||||||
|
|
||||||
|
/** The history snapshot: list fields, `deletedAt`, and the ordered items (so removals are restorable). */
|
||||||
|
async function snapshot(k: Executor, l: List): Promise<Record<string, unknown>> {
|
||||||
|
const s: Record<string, unknown> = { deletedAt: l.deletedAt };
|
||||||
|
for (const key of LIST_KEYS) s[key] = l[key];
|
||||||
|
s.items = (await items(k, l.id)).map((i) => ({ id: i.id, workId: i.workId, note: i.note }));
|
||||||
|
return s;
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalise(patch: Partial<ListPatch>): Partial<ListPatch> {
|
||||||
|
checkKeys("patch", patch, LIST_KEYS);
|
||||||
|
const out: Partial<ListPatch> = {};
|
||||||
|
if ("name" in patch) {
|
||||||
|
if (typeof patch.name !== "string" || !patch.name.trim()) throw new ValidationError("name", "required");
|
||||||
|
out.name = patch.name.trim();
|
||||||
|
}
|
||||||
|
if ("description" in patch) out.description = checkText("description", patch.description) ?? "";
|
||||||
|
if ("kind" in patch) {
|
||||||
|
if (!KINDS.includes(patch.kind as ListKind)) throw new ValidationError("kind", "invalid");
|
||||||
|
out.kind = patch.kind;
|
||||||
|
}
|
||||||
|
if ("visibility" in patch) {
|
||||||
|
if (!VISIBILITIES.includes(patch.visibility as Visibility)) throw new ValidationError("visibility", "invalid");
|
||||||
|
out.visibility = patch.visibility;
|
||||||
|
}
|
||||||
|
if ("rating" in patch) out.rating = checkRating("rating", patch.rating);
|
||||||
|
if ("review" in patch) out.review = checkText("review", patch.review);
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
function checkGrouping(l: Pick<ListPatch, "kind" | "rating" | "review">): void {
|
||||||
|
if (l.kind !== "grouping" && (l.rating !== null || l.review !== null)) {
|
||||||
|
throw new ValidationError(l.rating !== null ? "rating" : "review", "grouping_only");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function toColumns(p: Partial<ListPatch>): Partial<Row> {
|
||||||
|
const c: Partial<Row> = {};
|
||||||
|
if (p.name !== undefined) c.name = p.name;
|
||||||
|
if (p.description !== undefined) c.description = p.description;
|
||||||
|
if (p.kind !== undefined) c.kind = p.kind;
|
||||||
|
if (p.visibility !== undefined) c.visibility = p.visibility;
|
||||||
|
if (p.rating !== undefined) c.rating = p.rating;
|
||||||
|
if (p.review !== undefined) c.review = p.review;
|
||||||
|
return c;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function load(k: Executor, userId: string, listId: string, includeDeleted = false): Promise<List> {
|
||||||
|
let q = k.selectFrom("lists").selectAll().where("id", "=", listId).where("user_id", "=", userId);
|
||||||
|
if (!includeDeleted) q = q.where("deleted_at", "is", null);
|
||||||
|
const r = await q.executeTakeFirst();
|
||||||
|
if (!r) throw new NotFoundError("list");
|
||||||
|
return toList(r);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function items(k: Executor, listId: string): Promise<ListItem[]> {
|
||||||
|
const rows = await k
|
||||||
|
.selectFrom("list_items")
|
||||||
|
.selectAll()
|
||||||
|
.where("list_id", "=", listId)
|
||||||
|
.orderBy("position")
|
||||||
|
.orderBy("id")
|
||||||
|
.execute();
|
||||||
|
return rows.map(toItem);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Rewrite positions to 0..n-1 in the given order. */
|
||||||
|
async function renumber(k: Executor, ordered: ListItem[]): Promise<void> {
|
||||||
|
for (const [i, it] of ordered.entries()) {
|
||||||
|
if (it.position !== i) await k.updateTable("list_items").set({ position: i }).where("id", "=", it.id).execute();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Run `change` on a list (live, user's), then bump `updated_at` and record history. */
|
||||||
|
async function mutate<T>(
|
||||||
|
k: Executor,
|
||||||
|
userId: string,
|
||||||
|
listId: string,
|
||||||
|
actor: Actor,
|
||||||
|
change: (l: List) => Promise<T>,
|
||||||
|
opts: { includeDeleted?: boolean } = {},
|
||||||
|
): Promise<T> {
|
||||||
|
const before = await load(k, userId, listId, opts.includeDeleted);
|
||||||
|
const beforeSnap = await snapshot(k, before);
|
||||||
|
const result = await change(before);
|
||||||
|
await k.updateTable("lists").set({ updated_at: nowIso() }).where("id", "=", listId).execute();
|
||||||
|
const after = await load(k, userId, listId, true);
|
||||||
|
await recordHistory(k, {
|
||||||
|
userId,
|
||||||
|
entity: "list",
|
||||||
|
entityId: listId,
|
||||||
|
before: beforeSnap,
|
||||||
|
after: await snapshot(k, after),
|
||||||
|
actor,
|
||||||
|
});
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Create a list. Default kind `list`, visibility from user preferences. */
|
||||||
|
export async function createList(
|
||||||
|
db: Db,
|
||||||
|
userId: string,
|
||||||
|
input: Partial<ListPatch> & { name: string },
|
||||||
|
actor: Actor = "user",
|
||||||
|
tx?: Executor,
|
||||||
|
): Promise<List> {
|
||||||
|
return inTx(db, tx, async (k) => {
|
||||||
|
const p = normalise(input);
|
||||||
|
if (p.name === undefined) throw new ValidationError("name", "required");
|
||||||
|
const now = nowIso();
|
||||||
|
const row: Row = {
|
||||||
|
id: newId(),
|
||||||
|
user_id: userId,
|
||||||
|
name: p.name,
|
||||||
|
description: p.description ?? "",
|
||||||
|
kind: p.kind ?? "list",
|
||||||
|
visibility: p.visibility ?? (await defaultVisibility(k, userId)),
|
||||||
|
rating: p.rating ?? null,
|
||||||
|
review: p.review ?? null,
|
||||||
|
created_at: now,
|
||||||
|
updated_at: now,
|
||||||
|
deleted_at: null,
|
||||||
|
};
|
||||||
|
checkGrouping(row);
|
||||||
|
await k.insertInto("lists").values(row).execute();
|
||||||
|
const list = toList(row);
|
||||||
|
await recordHistory(k, {
|
||||||
|
userId,
|
||||||
|
entity: "list",
|
||||||
|
entityId: list.id,
|
||||||
|
before: null,
|
||||||
|
after: await snapshot(k, list),
|
||||||
|
actor,
|
||||||
|
});
|
||||||
|
return list;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Patch a list's own fields. */
|
||||||
|
export async function updateList(
|
||||||
|
db: Db,
|
||||||
|
userId: string,
|
||||||
|
listId: string,
|
||||||
|
patch: Partial<ListPatch>,
|
||||||
|
actor: Actor = "user",
|
||||||
|
tx?: Executor,
|
||||||
|
): Promise<List> {
|
||||||
|
return inTx(db, tx, async (k) => {
|
||||||
|
const p = normalise(patch);
|
||||||
|
await mutate(k, userId, listId, actor, async (l) => {
|
||||||
|
checkGrouping({ ...l, ...p });
|
||||||
|
const cols = toColumns(p);
|
||||||
|
if (Object.keys(cols).length) await k.updateTable("lists").set(cols).where("id", "=", listId).execute();
|
||||||
|
});
|
||||||
|
return load(k, userId, listId);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Soft-delete a list (its items stay, for restore). */
|
||||||
|
export async function softDeleteList(
|
||||||
|
db: Db,
|
||||||
|
userId: string,
|
||||||
|
listId: string,
|
||||||
|
actor: Actor = "user",
|
||||||
|
tx?: Executor,
|
||||||
|
): Promise<void> {
|
||||||
|
await inTx(db, tx, (k) =>
|
||||||
|
mutate(k, userId, listId, actor, async () => {
|
||||||
|
await k.updateTable("lists").set({ deleted_at: nowIso() }).where("id", "=", listId).execute();
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Add a work to a list, at `position` (0-based; default: the end). */
|
||||||
|
export async function addListItem(
|
||||||
|
db: Db,
|
||||||
|
userId: string,
|
||||||
|
listId: string,
|
||||||
|
input: { workId: string; note?: string | null; position?: number },
|
||||||
|
actor: Actor = "user",
|
||||||
|
tx?: Executor,
|
||||||
|
): Promise<ListItem> {
|
||||||
|
return inTx(db, tx, (k) =>
|
||||||
|
mutate(k, userId, listId, actor, async () => {
|
||||||
|
const work = await resolveVisibleWork(k, userId, input.workId);
|
||||||
|
const note = input.note === undefined ? null : checkText("note", input.note);
|
||||||
|
const current = await items(k, listId);
|
||||||
|
const at = clampIndex(input.position ?? current.length, current.length);
|
||||||
|
const item: ListItem = { id: newId(), listId, workId: work.id, position: current.length, note };
|
||||||
|
await k
|
||||||
|
.insertInto("list_items")
|
||||||
|
.values({ id: item.id, list_id: listId, work_id: item.workId, position: item.position, note })
|
||||||
|
.execute();
|
||||||
|
current.splice(at, 0, item);
|
||||||
|
await renumber(k, current);
|
||||||
|
return { ...item, position: at };
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Replace a list's items with `next`, in order (one history row). Used by the sync engine. */
|
||||||
|
export async function replaceListItems(
|
||||||
|
db: Db,
|
||||||
|
userId: string,
|
||||||
|
listId: string,
|
||||||
|
next: { workId: string; note?: string | null }[],
|
||||||
|
actor: Actor = "user",
|
||||||
|
tx?: Executor,
|
||||||
|
): Promise<void> {
|
||||||
|
await inTx(db, tx, (k) =>
|
||||||
|
mutate(k, userId, listId, actor, async () => {
|
||||||
|
const rows: ItemRow[] = [];
|
||||||
|
for (const [i, it] of next.entries()) {
|
||||||
|
const work = await resolveVisibleWork(k, userId, it.workId);
|
||||||
|
rows.push({ id: newId(), list_id: listId, work_id: work.id, position: i, note: it.note == null ? null : checkText("note", it.note) });
|
||||||
|
}
|
||||||
|
await k.deleteFrom("list_items").where("list_id", "=", listId).execute();
|
||||||
|
if (rows.length > 0) await k.insertInto("list_items").values(rows).execute();
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function clampIndex(i: number, max: number): number {
|
||||||
|
if (typeof i !== "number" || !Number.isInteger(i)) throw new ValidationError("position", "not_integer");
|
||||||
|
return Math.max(0, Math.min(i, max));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Load an item of one of the user's live lists. */
|
||||||
|
async function loadItem(k: Executor, userId: string, itemId: string): Promise<ListItem> {
|
||||||
|
const r = await k
|
||||||
|
.selectFrom("list_items as i")
|
||||||
|
.innerJoin("lists as l", "l.id", "i.list_id")
|
||||||
|
.selectAll("i")
|
||||||
|
.where("i.id", "=", itemId)
|
||||||
|
.where("l.user_id", "=", userId)
|
||||||
|
.where("l.deleted_at", "is", null)
|
||||||
|
.executeTakeFirst();
|
||||||
|
if (!r) throw new NotFoundError("list_item");
|
||||||
|
return toItem(r);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Move an item to `toIndex` (clamped to the list bounds). */
|
||||||
|
export async function moveListItem(
|
||||||
|
db: Db,
|
||||||
|
userId: string,
|
||||||
|
itemId: string,
|
||||||
|
toIndex: number,
|
||||||
|
actor: Actor = "user",
|
||||||
|
tx?: Executor,
|
||||||
|
): Promise<void> {
|
||||||
|
await inTx(db, tx, async (k) => {
|
||||||
|
const item = await loadItem(k, userId, itemId);
|
||||||
|
await mutate(k, userId, item.listId, actor, async () => {
|
||||||
|
const current = (await items(k, item.listId)).filter((i) => i.id !== itemId);
|
||||||
|
current.splice(clampIndex(toIndex, current.length), 0, item);
|
||||||
|
await renumber(k, current);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Remove an item. The list's history keeps it, so `restoreVersion` can bring it back. */
|
||||||
|
export async function removeListItem(
|
||||||
|
db: Db,
|
||||||
|
userId: string,
|
||||||
|
itemId: string,
|
||||||
|
actor: Actor = "user",
|
||||||
|
tx?: Executor,
|
||||||
|
): Promise<void> {
|
||||||
|
await inTx(db, tx, async (k) => {
|
||||||
|
const item = await loadItem(k, userId, itemId);
|
||||||
|
await mutate(k, userId, item.listId, actor, async () => {
|
||||||
|
await k.deleteFrom("list_items").where("id", "=", itemId).execute();
|
||||||
|
await renumber(k, await items(k, item.listId));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A live list of the user's with its items in order. */
|
||||||
|
export async function getList(db: Db, userId: string, listId: string): Promise<ListWithItems> {
|
||||||
|
const list = await load(db.k, userId, listId);
|
||||||
|
const rows = await db.k
|
||||||
|
.selectFrom("list_items as i")
|
||||||
|
.innerJoin("works as w", "w.id", "i.work_id")
|
||||||
|
.selectAll("i")
|
||||||
|
.select(["w.title", "w.media_type", "w.year", "w.cover_url"])
|
||||||
|
.where("i.list_id", "=", listId)
|
||||||
|
.orderBy("i.position")
|
||||||
|
.orderBy("i.id")
|
||||||
|
.execute();
|
||||||
|
return {
|
||||||
|
...list,
|
||||||
|
items: rows.map((r) => ({
|
||||||
|
...toItem(r),
|
||||||
|
work: { id: r.work_id, title: r.title, mediaType: r.media_type, year: r.year, coverUrl: r.cover_url },
|
||||||
|
})),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The user's live lists, most recently updated first. */
|
||||||
|
export async function listLists(db: Db, userId: string, filter: { kind?: ListKind } = {}): Promise<List[]> {
|
||||||
|
let q = db.k.selectFrom("lists").selectAll().where("user_id", "=", userId).where("deleted_at", "is", null);
|
||||||
|
if (filter.kind) q = q.where("kind", "=", filter.kind);
|
||||||
|
const rows = await q.orderBy("updated_at", "desc").orderBy("id", "desc").execute();
|
||||||
|
return rows.map(toList);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Called by `restoreVersion`: re-apply a history row's `before` (fields, deletion and items) as actor "user". */
|
||||||
|
export async function restoreListFromHistory(
|
||||||
|
k: Executor,
|
||||||
|
userId: string,
|
||||||
|
listId: string,
|
||||||
|
before: Record<string, unknown>,
|
||||||
|
): Promise<void> {
|
||||||
|
const patch: Record<string, unknown> = {};
|
||||||
|
for (const key of LIST_KEYS) if (key in before) patch[key] = before[key];
|
||||||
|
const p = normalise(patch as Partial<ListPatch>);
|
||||||
|
await mutate(
|
||||||
|
k,
|
||||||
|
userId,
|
||||||
|
listId,
|
||||||
|
"user",
|
||||||
|
async (l) => {
|
||||||
|
checkGrouping({ ...l, ...p });
|
||||||
|
const cols: Partial<Row> = toColumns(p);
|
||||||
|
if ("deletedAt" in before) cols.deleted_at = (before.deletedAt as string | null) ?? null;
|
||||||
|
if (Object.keys(cols).length) await k.updateTable("lists").set(cols).where("id", "=", listId).execute();
|
||||||
|
if (Array.isArray(before.items)) {
|
||||||
|
await k.deleteFrom("list_items").where("list_id", "=", listId).execute();
|
||||||
|
for (const [position, it] of (before.items as { id: string; workId: string; note: string | null }[]).entries()) {
|
||||||
|
await k
|
||||||
|
.insertInto("list_items")
|
||||||
|
.values({ id: it.id, list_id: listId, work_id: it.workId, position, note: it.note ?? null })
|
||||||
|
.execute();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{ includeDeleted: true },
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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,65 @@
|
|||||||
|
import { readdir } from "node:fs/promises";
|
||||||
|
import { join } from "node:path";
|
||||||
|
import { pathToFileURL } from "node:url";
|
||||||
|
import type { PluginManifest, SourcePlugin } from "@sttracker/plugin-sdk/types";
|
||||||
|
|
||||||
|
export interface PluginRegistry {
|
||||||
|
get(id: string): SourcePlugin | undefined;
|
||||||
|
list(): PluginManifest[];
|
||||||
|
reload(): Promise<void>;
|
||||||
|
}
|
||||||
|
|
||||||
|
function validShape(p: unknown): p is SourcePlugin {
|
||||||
|
const m = (p as SourcePlugin | undefined)?.manifest;
|
||||||
|
return (
|
||||||
|
!!m &&
|
||||||
|
typeof m.id === "string" &&
|
||||||
|
m.id.trim() !== "" &&
|
||||||
|
m.sdkVersion === 1 &&
|
||||||
|
typeof m.name === "string" &&
|
||||||
|
Array.isArray(m.capabilities) &&
|
||||||
|
Array.isArray(m.config)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function scan(dirs: string[]): Promise<Map<string, SourcePlugin>> {
|
||||||
|
const found = new Map<string, SourcePlugin>();
|
||||||
|
const stamp = Date.now();
|
||||||
|
for (const dir of dirs) {
|
||||||
|
let names: string[];
|
||||||
|
try {
|
||||||
|
names = (await readdir(dir, { withFileTypes: true })).filter((d) => d.isDirectory()).map((d) => d.name).sort();
|
||||||
|
} catch (e) {
|
||||||
|
console.warn(`plugins: cannot read ${dir}: ${e instanceof Error ? e.message : String(e)}`);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
for (const name of names) {
|
||||||
|
const file = join(dir, name, "index.ts");
|
||||||
|
if (!(await Bun.file(file).exists())) continue;
|
||||||
|
try {
|
||||||
|
// Bun ignores the `?v=` query for cached .ts modules; evicting the cache entry is what makes reload work.
|
||||||
|
delete require.cache[file];
|
||||||
|
const mod = (await import(`${pathToFileURL(file).href}?v=${stamp}`)) as { default?: unknown };
|
||||||
|
if (!validShape(mod.default)) throw new Error("default export is not a valid plugin (manifest, sdkVersion 1)");
|
||||||
|
const id = mod.default.manifest.id;
|
||||||
|
if (found.has(id)) throw new Error(`duplicate plugin id "${id}"`);
|
||||||
|
found.set(id, mod.default);
|
||||||
|
} catch (e) {
|
||||||
|
console.warn(`plugins: skipping ${file}: ${e instanceof Error ? e.message : String(e)}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return found;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Load every `<dir>/<name>/index.ts` plugin. A bad plugin is logged and skipped, never fatal. */
|
||||||
|
export async function loadPlugins(dirs: string[]): Promise<PluginRegistry> {
|
||||||
|
let plugins = await scan(dirs);
|
||||||
|
return {
|
||||||
|
get: (id) => plugins.get(id),
|
||||||
|
list: () => [...plugins.values()].map((p) => p.manifest),
|
||||||
|
async reload() {
|
||||||
|
plugins = await scan(dirs);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,815 @@
|
|||||||
|
import { sql } from "kysely";
|
||||||
|
import type { Db } from "@sttracker/db/db";
|
||||||
|
import { newId, nowIso } from "@sttracker/db/ids";
|
||||||
|
import { fromJson, toJson } from "@sttracker/db/json";
|
||||||
|
import type { ChangeStatus, ChangeType, Database } from "@sttracker/db/schema";
|
||||||
|
import type { EngagementFields, EntryFields, PartialDate, SourceRecord, WorkHint } from "@sttracker/plugin-sdk/types";
|
||||||
|
import { addEngagement, type EngagementPatch, updateEngagement } from "../engagements.ts";
|
||||||
|
import { type EntryPatch, fromStoredDate, resolveVisibleWork, upsertEntry } from "../entries.ts";
|
||||||
|
import type { Actor, Executor } from "../history.ts";
|
||||||
|
import { createList, replaceListItems, updateList } from "../lists.ts";
|
||||||
|
import { matchWork } from "../matching.ts";
|
||||||
|
import { resolveAliasIn, updateWorkFromHintIn } from "../works.ts";
|
||||||
|
import { decodeVersion, encodeVersion, hashRecord, type NormalizedRecord, normalizeRecord, sameValue } from "./snapshot.ts";
|
||||||
|
|
||||||
|
export interface RunCounts {
|
||||||
|
seen: number;
|
||||||
|
added: number;
|
||||||
|
changed: number;
|
||||||
|
removed: number;
|
||||||
|
merged: number;
|
||||||
|
conflicts: number;
|
||||||
|
autoApplied: number;
|
||||||
|
pending: number;
|
||||||
|
listens: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function emptyCounts(): RunCounts {
|
||||||
|
return { seen: 0, added: 0, changed: 0, removed: 0, merged: 0, conflicts: 0, autoApplied: 0, pending: 0, listens: 0 };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Everything one run needs while applying records. */
|
||||||
|
export interface SyncState {
|
||||||
|
db: Db;
|
||||||
|
userId: string;
|
||||||
|
connectionId: string;
|
||||||
|
pluginId: string;
|
||||||
|
runId: string;
|
||||||
|
actor: Actor;
|
||||||
|
counts: RunCounts;
|
||||||
|
log: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Run `fn` against scratch counts and log, and fold them into the run's only when `fn` resolves, so
|
||||||
|
* the run summary reflects committed work only.
|
||||||
|
*/
|
||||||
|
export async function committed(st: SyncState, fn: (local: SyncState) => Promise<void>): Promise<void> {
|
||||||
|
const local: SyncState = { ...st, counts: emptyCounts(), log: [] };
|
||||||
|
await fn(local);
|
||||||
|
for (const key of Object.keys(local.counts) as (keyof RunCounts)[]) st.counts[key] += local.counts[key];
|
||||||
|
st.log.push(...local.log);
|
||||||
|
}
|
||||||
|
|
||||||
|
type SourceRecordRow = Database["source_records"];
|
||||||
|
type Fields = Record<string, unknown>;
|
||||||
|
type ListItemValue = { workId: string; note: string | null };
|
||||||
|
|
||||||
|
/**
|
||||||
|
* `source_records.current_hash` of a record that has been matched (its work id stored as
|
||||||
|
* `target_entity "work"`) but whose apply has not committed yet. A retry reuses the stored work.
|
||||||
|
*/
|
||||||
|
const PENDING_HASH = "";
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------------------------
|
||||||
|
// Field mapping between SourceRecord fields and the user layer's patch keys
|
||||||
|
// ---------------------------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/** SDK `EntryFields` → `EntryPatch` keys (`notes` is the entry's private notes). Absent keys stay absent. */
|
||||||
|
export function entryPatchFrom(f: EntryFields | Record<string, unknown> | undefined): Fields {
|
||||||
|
const src = (f ?? {}) as EntryFields;
|
||||||
|
const out: Fields = {};
|
||||||
|
if (src.status !== undefined) out.status = src.status;
|
||||||
|
if (src.rating !== undefined) out.rating = src.rating;
|
||||||
|
if (src.review !== undefined) out.review = src.review;
|
||||||
|
if (src.notes !== undefined) out.privateNotes = src.notes;
|
||||||
|
if (src.favorite !== undefined) out.favorite = src.favorite;
|
||||||
|
if (src.tags !== undefined) out.tags = src.tags;
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** SDK `EngagementFields` → `EngagementPatch` keys. An "unknown"-precision date is `null`. */
|
||||||
|
export function engagementPatchFrom(f: EngagementFields | Record<string, unknown> | undefined): Fields {
|
||||||
|
const src = (f ?? {}) as EngagementFields;
|
||||||
|
const date = (d: PartialDate | null) => (d === null || d.precision === "unknown" ? null : d);
|
||||||
|
const out: Fields = {};
|
||||||
|
if (src.startedAt !== undefined) out.startedAt = date(src.startedAt);
|
||||||
|
if (src.finishedAt !== undefined) out.finishedAt = date(src.finishedAt);
|
||||||
|
if (src.progress !== undefined) out.progress = src.progress;
|
||||||
|
if (src.platform !== undefined) out.platform = src.platform;
|
||||||
|
if (src.rating !== undefined) out.rating = src.rating;
|
||||||
|
if (src.notes !== undefined) out.notes = src.notes;
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A list record's own fields (items are matched separately). */
|
||||||
|
function listPatchFrom(l: Record<string, unknown> | undefined): Fields {
|
||||||
|
const out: Fields = {};
|
||||||
|
if (typeof l?.name === "string") out.name = l.name;
|
||||||
|
if (typeof l?.description === "string") out.description = l.description;
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** An entry row's current values under the sync-managed patch keys. */
|
||||||
|
export function entryValues(r: Database["entries"]): Fields {
|
||||||
|
return {
|
||||||
|
status: r.status,
|
||||||
|
rating: r.rating,
|
||||||
|
review: r.review,
|
||||||
|
privateNotes: r.private_notes,
|
||||||
|
favorite: Boolean(r.favorite),
|
||||||
|
tags: fromJson<string[]>(r.tags, []),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** An engagement row's current values under the sync-managed patch keys. */
|
||||||
|
export function engagementValues(r: Database["engagements"]): Fields {
|
||||||
|
return {
|
||||||
|
startedAt: fromStoredDate(r.started_at, r.started_precision),
|
||||||
|
finishedAt: fromStoredDate(r.finished_at, r.finished_precision),
|
||||||
|
progress: fromJson<Record<string, unknown>>(r.progress, {}),
|
||||||
|
platform: r.platform,
|
||||||
|
rating: r.rating,
|
||||||
|
notes: r.notes,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A list's current values: name, description and ordered items. */
|
||||||
|
export async function listValues(k: Executor, r: Database["lists"]): Promise<Fields> {
|
||||||
|
const items = await k.selectFrom("list_items").select(["work_id", "note"]).where("list_id", "=", r.id).orderBy("position").orderBy("id").execute();
|
||||||
|
return { name: r.name, description: r.description, items: items.map((i): ListItemValue => ({ workId: i.work_id, note: i.note })) };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Lists carry no `field_sources`, so ownership is read from their history: `name`/`description` belong
|
||||||
|
* to whoever last changed them, and `items` to the user once any user edit exists, else to the source.
|
||||||
|
*/
|
||||||
|
export async function listFieldSources(k: Executor, listId: string, sourceActor: Actor): Promise<Record<string, string>> {
|
||||||
|
const rows = await k
|
||||||
|
.selectFrom("user_history")
|
||||||
|
.select(["after", "actor"])
|
||||||
|
.where("entity", "=", "list")
|
||||||
|
.where("entity_id", "=", listId)
|
||||||
|
.orderBy("created_at")
|
||||||
|
.orderBy("id")
|
||||||
|
.execute();
|
||||||
|
const fs: Record<string, string> = { items: sourceActor };
|
||||||
|
for (const r of rows) {
|
||||||
|
const after = fromJson<Fields>(r.after, {});
|
||||||
|
for (const f of ["name", "description"]) if (f in after) fs[f] = r.actor;
|
||||||
|
if (r.actor === "user") fs.items = "user";
|
||||||
|
}
|
||||||
|
return fs;
|
||||||
|
}
|
||||||
|
|
||||||
|
function patchOf(n: NormalizedRecord | undefined): Fields {
|
||||||
|
if (!n) return {};
|
||||||
|
if (n.kind === "entry") return entryPatchFrom(n.entry);
|
||||||
|
if (n.kind === "engagement") return engagementPatchFrom(n.engagement);
|
||||||
|
if (n.kind === "list") return listPatchFrom(n.list);
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------------------------
|
||||||
|
// Change events
|
||||||
|
// ---------------------------------------------------------------------------------------------
|
||||||
|
|
||||||
|
async function writeEvent(
|
||||||
|
k: Executor,
|
||||||
|
st: SyncState,
|
||||||
|
e: { id?: string; recordId: string; type: ChangeType; status: ChangeStatus; targetEntity: string; targetId: string | null; payload: Fields },
|
||||||
|
): Promise<void> {
|
||||||
|
await k
|
||||||
|
.insertInto("change_events")
|
||||||
|
.values({
|
||||||
|
id: e.id ?? newId(),
|
||||||
|
user_id: st.userId,
|
||||||
|
connection_id: st.connectionId,
|
||||||
|
run_id: st.runId,
|
||||||
|
record_id: e.recordId,
|
||||||
|
type: e.type,
|
||||||
|
target_entity: e.targetEntity,
|
||||||
|
target_id: e.targetId,
|
||||||
|
payload: toJson(e.payload),
|
||||||
|
status: e.status,
|
||||||
|
created_at: nowIso(),
|
||||||
|
resolved_at: null,
|
||||||
|
})
|
||||||
|
.execute();
|
||||||
|
if (e.status === "auto_applied") st.counts.autoApplied++;
|
||||||
|
if (e.status === "pending") st.counts.pending++;
|
||||||
|
if (e.status === "pending" && e.type === "conflict") st.counts.conflicts++;
|
||||||
|
}
|
||||||
|
|
||||||
|
const incomingOf = (p: Fields) => ("incoming" in p ? p.incoming : p.to);
|
||||||
|
|
||||||
|
/** Pending changed/conflict events of a record for one field. */
|
||||||
|
async function pendingFieldEvents(k: Executor, recordId: string, field: string) {
|
||||||
|
const rows = await k
|
||||||
|
.selectFrom("change_events")
|
||||||
|
.select(["id", "payload"])
|
||||||
|
.where("record_id", "=", recordId)
|
||||||
|
.where("status", "=", "pending")
|
||||||
|
.where("type", "in", ["changed", "conflict"])
|
||||||
|
.execute();
|
||||||
|
return rows.map((r) => ({ id: r.id, payload: fromJson<Fields>(r.payload, {}) })).filter((r) => r.payload.field === field);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Resolve stale pending events as `ignored`, recording why in their payload. */
|
||||||
|
async function retire(k: Executor, stale: { id: string; payload: Fields }[], extra: Fields): Promise<void> {
|
||||||
|
for (const s of stale) {
|
||||||
|
await k
|
||||||
|
.updateTable("change_events")
|
||||||
|
.set({ status: "ignored", resolved_at: nowIso(), payload: toJson({ ...s.payload, ...extra }) })
|
||||||
|
.where("id", "=", s.id)
|
||||||
|
.execute();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Target {
|
||||||
|
entity: "entry" | "engagement" | "list";
|
||||||
|
id: string;
|
||||||
|
current: Fields;
|
||||||
|
fieldSources: Record<string, string>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Decide each incoming field against the target's field ownership (spec §8.3):
|
||||||
|
* unset or this connection → applied; `"user"` → pending `changed`; another source → pending `conflict`.
|
||||||
|
* Values equal to the current one are skipped, so the plugin never takes ownership of a field it did not change.
|
||||||
|
* Anti-flip-flop: a pending event for the same (record, field, incoming value) is never filed twice, and a
|
||||||
|
* newer incoming value supersedes an older pending one (`ignored`, `payload.supersededBy`).
|
||||||
|
* Returns the patch that may be applied, with each applied field's previous value.
|
||||||
|
*/
|
||||||
|
async function decideFields(
|
||||||
|
k: Executor,
|
||||||
|
st: SyncState,
|
||||||
|
recordId: string,
|
||||||
|
t: Target,
|
||||||
|
incoming: Fields,
|
||||||
|
): Promise<{ patch: Fields; from: Fields }> {
|
||||||
|
const patch: Fields = {};
|
||||||
|
const from: Fields = {};
|
||||||
|
for (const [field, value] of Object.entries(incoming)) {
|
||||||
|
const current = t.current[field];
|
||||||
|
const pending = await pendingFieldEvents(k, recordId, field);
|
||||||
|
if (sameValue(current, value)) {
|
||||||
|
// The source now agrees with what STTracker holds: its older proposals are moot.
|
||||||
|
await retire(k, pending, { reason: "source_agrees" });
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const source = t.fieldSources[field];
|
||||||
|
if (source === undefined || source === st.actor) {
|
||||||
|
patch[field] = value;
|
||||||
|
from[field] = current ?? null;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (pending.some((p) => sameValue(incomingOf(p.payload), value))) continue;
|
||||||
|
const id = newId();
|
||||||
|
await retire(k, pending, { supersededBy: id });
|
||||||
|
if (source === "user") {
|
||||||
|
await writeEvent(k, st, {
|
||||||
|
id,
|
||||||
|
recordId,
|
||||||
|
type: "changed",
|
||||||
|
status: "pending",
|
||||||
|
targetEntity: t.entity,
|
||||||
|
targetId: t.id,
|
||||||
|
payload: { field, from: current ?? null, to: value },
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
await writeEvent(k, st, {
|
||||||
|
id,
|
||||||
|
recordId,
|
||||||
|
type: "conflict",
|
||||||
|
status: "pending",
|
||||||
|
targetEntity: t.entity,
|
||||||
|
targetId: t.id,
|
||||||
|
payload: { field, current: current ?? null, currentSource: source, incoming: value, incomingSource: st.actor },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return { patch, from };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Write an allowed patch to the target as this connection's actor. */
|
||||||
|
export async function applyPatch(k: Executor, db: Db, userId: string, t: Pick<Target, "entity" | "id">, patch: Fields, actor: Actor): Promise<void> {
|
||||||
|
if (Object.keys(patch).length === 0) return;
|
||||||
|
if (t.entity === "entry") {
|
||||||
|
const row = await k.selectFrom("entries").select("work_id").where("id", "=", t.id).executeTakeFirstOrThrow();
|
||||||
|
await upsertEntry(db, userId, row.work_id, patch as Partial<EntryPatch>, actor, k);
|
||||||
|
} else if (t.entity === "engagement") {
|
||||||
|
await updateEngagement(db, userId, t.id, patch as Partial<EngagementPatch>, actor, k);
|
||||||
|
} else {
|
||||||
|
const { items, ...own } = patch;
|
||||||
|
if (Object.keys(own).length > 0) await updateList(db, userId, t.id, own, actor, k);
|
||||||
|
if (items !== undefined) await replaceListItems(db, userId, t.id, items as ListItemValue[], actor, k);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The live target of a mapped record, or null (unmapped, gone, or soft-deleted: never revived). */
|
||||||
|
async function loadTarget(k: Executor, st: SyncState, entity: string | null, id: string | null): Promise<Target | null> {
|
||||||
|
if (!id) return null;
|
||||||
|
if (entity === "entry") {
|
||||||
|
const r = await k
|
||||||
|
.selectFrom("entries")
|
||||||
|
.selectAll()
|
||||||
|
.where("id", "=", id)
|
||||||
|
.where("user_id", "=", st.userId)
|
||||||
|
.where("deleted_at", "is", null)
|
||||||
|
.executeTakeFirst();
|
||||||
|
return r ? { entity, id, current: entryValues(r), fieldSources: fromJson(r.field_sources, {}) } : null;
|
||||||
|
}
|
||||||
|
if (entity === "engagement") {
|
||||||
|
const r = await k
|
||||||
|
.selectFrom("engagements as g")
|
||||||
|
.innerJoin("entries as e", "e.id", "g.entry_id")
|
||||||
|
.selectAll("g")
|
||||||
|
.where("g.id", "=", id)
|
||||||
|
.where("e.user_id", "=", st.userId)
|
||||||
|
.where("g.deleted_at", "is", null)
|
||||||
|
.where("e.deleted_at", "is", null)
|
||||||
|
.executeTakeFirst();
|
||||||
|
return r ? { entity, id, current: engagementValues(r), fieldSources: fromJson(r.field_sources, {}) } : null;
|
||||||
|
}
|
||||||
|
if (entity === "list") {
|
||||||
|
const r = await k
|
||||||
|
.selectFrom("lists")
|
||||||
|
.selectAll()
|
||||||
|
.where("id", "=", id)
|
||||||
|
.where("user_id", "=", st.userId)
|
||||||
|
.where("deleted_at", "is", null)
|
||||||
|
.executeTakeFirst();
|
||||||
|
return r ? { entity, id, current: await listValues(k, r), fieldSources: await listFieldSources(k, id, st.actor) } : null;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------------------------
|
||||||
|
// Entry creation helpers
|
||||||
|
// ---------------------------------------------------------------------------------------------
|
||||||
|
|
||||||
|
async function findEntryRow(k: Executor, userId: string, workId: string) {
|
||||||
|
return k.selectFrom("entries").selectAll().where("user_id", "=", userId).where("work_id", "=", workId).executeTakeFirst();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Hierarchy ruling: when the engine creates an entry on a child work, the user also gets an entry on
|
||||||
|
* the root ancestor (status "none", plugin actor) unless one exists. An existing root entry, live or
|
||||||
|
* deleted, is never touched.
|
||||||
|
*/
|
||||||
|
async function ensureRootEntry(k: Executor, st: SyncState, workId: string): Promise<void> {
|
||||||
|
let root = workId;
|
||||||
|
for (let hop = 0; hop < 32; hop++) {
|
||||||
|
const w = await k.selectFrom("works").select("parent_id").where("id", "=", root).executeTakeFirst();
|
||||||
|
if (!w?.parent_id) break;
|
||||||
|
root = w.parent_id;
|
||||||
|
}
|
||||||
|
if (root === workId) return;
|
||||||
|
root = await resolveAliasIn(k, st.userId, root);
|
||||||
|
if (await findEntryRow(k, st.userId, root)) return;
|
||||||
|
await upsertEntry(st.db, st.userId, root, {}, st.actor, k);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The user's live entry for a work, created (with root ancestor) when missing. Null when the user
|
||||||
|
* deleted it: syncs never revive.
|
||||||
|
*/
|
||||||
|
async function liveEntryFor(
|
||||||
|
k: Executor,
|
||||||
|
st: SyncState,
|
||||||
|
workId: string,
|
||||||
|
patch: Fields,
|
||||||
|
): Promise<{ row: Database["entries"]; created: boolean } | null> {
|
||||||
|
const work = await resolveVisibleWork(k, st.userId, workId);
|
||||||
|
const row = await findEntryRow(k, st.userId, work.id);
|
||||||
|
if (row) return row.deleted_at === null ? { row, created: false } : null;
|
||||||
|
const e = await upsertEntry(st.db, st.userId, work.id, patch as Partial<EntryPatch>, st.actor, k);
|
||||||
|
await ensureRootEntry(k, st, work.id);
|
||||||
|
const created = await k.selectFrom("entries").selectAll().where("id", "=", e.id).executeTakeFirstOrThrow();
|
||||||
|
return { row: created, created: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------------------------
|
||||||
|
// Engagement de-duplication (spec §8.3: same play from two sources)
|
||||||
|
// ---------------------------------------------------------------------------------------------
|
||||||
|
|
||||||
|
const DAY_MS = 86_400_000;
|
||||||
|
const hasTime = (v: string) => v.length > 10 && v[10] === "T";
|
||||||
|
const dayNumber = (v: string) => Date.UTC(Number(v.slice(0, 4)), Number(v.slice(5, 7)) - 1, Number(v.slice(8, 10))) / DAY_MS;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Finish dates "within 24 h". Two datetimes compare as instants. Otherwise day-precision values compare
|
||||||
|
* as calendar dates (a datetime's date part is its first 10 characters) and match when at most one day
|
||||||
|
* apart. Month/year/unknown precision never matches.
|
||||||
|
*/
|
||||||
|
export function finishedWithin24h(a: PartialDate, b: PartialDate): boolean {
|
||||||
|
if (a.precision !== "day" || b.precision !== "day") return false;
|
||||||
|
if (hasTime(a.value) && hasTime(b.value)) {
|
||||||
|
const x = Date.parse(a.value);
|
||||||
|
const y = Date.parse(b.value);
|
||||||
|
if (!Number.isNaN(x) && !Number.isNaN(y)) return Math.abs(x - y) <= DAY_MS;
|
||||||
|
}
|
||||||
|
return Math.abs(dayNumber(a.value) - dayNumber(b.value)) <= 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Equal start datetimes (both carry a time). Compared as instants, falling back to string equality. */
|
||||||
|
export function sameStartInstant(a: PartialDate, b: PartialDate): boolean {
|
||||||
|
if (!hasTime(a.value) || !hasTime(b.value)) return false;
|
||||||
|
const x = Date.parse(a.value);
|
||||||
|
const y = Date.parse(b.value);
|
||||||
|
return Number.isNaN(x) || Number.isNaN(y) ? a.value === b.value : x === y;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A live engagement on the entry that is the same play, not already reported by this connection.
|
||||||
|
* Also returns the date field that matched (its values differ only within the dedupe tolerance).
|
||||||
|
*/
|
||||||
|
async function findDuplicateEngagement(k: Executor, st: SyncState, entryId: string, incoming: Fields) {
|
||||||
|
const fin = incoming.finishedAt as PartialDate | null | undefined;
|
||||||
|
const start = incoming.startedAt as PartialDate | null | undefined;
|
||||||
|
if (!fin && !start) return null;
|
||||||
|
const rows = await k
|
||||||
|
.selectFrom("engagements")
|
||||||
|
.selectAll()
|
||||||
|
.where("entry_id", "=", entryId)
|
||||||
|
.where("deleted_at", "is", null)
|
||||||
|
.where((eb) =>
|
||||||
|
eb.not(
|
||||||
|
eb.exists(
|
||||||
|
eb
|
||||||
|
.selectFrom("engagement_sources as s")
|
||||||
|
.select("s.id")
|
||||||
|
.whereRef("s.engagement_id", "=", "engagements.id")
|
||||||
|
.where("s.connection_id", "=", st.connectionId),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.orderBy("id")
|
||||||
|
.execute();
|
||||||
|
for (const r of rows) {
|
||||||
|
const v = engagementValues(r);
|
||||||
|
const f = v.finishedAt as PartialDate | null;
|
||||||
|
const s = v.startedAt as PartialDate | null;
|
||||||
|
if (fin && f && finishedWithin24h(fin, f)) return { row: r, matchedOn: "finishedAt" };
|
||||||
|
if (start && s && sameStartInstant(start, s)) return { row: r, matchedOn: "startedAt" };
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const isEmptyValue = (v: unknown) =>
|
||||||
|
v === null || v === undefined || (typeof v === "object" && !Array.isArray(v) && Object.keys(v as object).length === 0);
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------------------------
|
||||||
|
// List items
|
||||||
|
// ---------------------------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Match list item hints to works, outside any transaction. An ID-less hint first reuses the user's own
|
||||||
|
* live private work with the same media type and title, so re-matching never piles up drafts.
|
||||||
|
*/
|
||||||
|
async function matchListItems(st: SyncState, items: { work: WorkHint; note?: string }[]): Promise<ListItemValue[]> {
|
||||||
|
const out: ListItemValue[] = [];
|
||||||
|
const seen = new Set<string>();
|
||||||
|
for (const it of items) {
|
||||||
|
let workId: string | undefined;
|
||||||
|
if (Object.keys(it.work.externalIds).length === 0) {
|
||||||
|
workId = (
|
||||||
|
await st.db.k
|
||||||
|
.selectFrom("works")
|
||||||
|
.select("id")
|
||||||
|
.where("owner_user_id", "=", st.userId)
|
||||||
|
.where("merged_into_id", "is", null)
|
||||||
|
.where("media_type", "=", it.work.mediaType)
|
||||||
|
.where(sql<boolean>`lower(title) = ${it.work.title.toLowerCase()}`)
|
||||||
|
.orderBy("id")
|
||||||
|
.executeTakeFirst()
|
||||||
|
)?.id;
|
||||||
|
}
|
||||||
|
workId ??= (await matchWork(st.db, st.userId, it.work, st.actor)).workId;
|
||||||
|
if (seen.has(workId)) continue;
|
||||||
|
seen.add(workId);
|
||||||
|
out.push({ workId, note: it.note ?? null });
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------------------------
|
||||||
|
// Records
|
||||||
|
// ---------------------------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Snapshot one non-listen record and apply it. Call outside any transaction (matchWork has its own).
|
||||||
|
* Counts land in `st` only once the record's work has committed.
|
||||||
|
*/
|
||||||
|
export function applyRecord(st: SyncState, rec: SourceRecord): Promise<void> {
|
||||||
|
return committed(st, async (local) => {
|
||||||
|
const hash = hashRecord(rec);
|
||||||
|
const existing = await local.db.k
|
||||||
|
.selectFrom("source_records")
|
||||||
|
.selectAll()
|
||||||
|
.where("connection_id", "=", local.connectionId)
|
||||||
|
.where("key", "=", rec.key)
|
||||||
|
.executeTakeFirst();
|
||||||
|
if (!existing || existing.current_hash === PENDING_HASH) return addRecord(local, rec, hash, existing ?? null);
|
||||||
|
if (existing.current_hash === hash) {
|
||||||
|
await local.db.k.transaction().execute(async (tx) => {
|
||||||
|
await tx.updateTable("source_records").set({ last_seen: nowIso() }).where("id", "=", existing.id).execute();
|
||||||
|
if (existing.deleted_at !== null && rec.deleted !== true) await reappeared(tx, existing);
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
return changeRecord(local, existing, rec, hash);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function addRecord(st: SyncState, rec: SourceRecord, hash: string, pendingRow: SourceRecordRow | null): Promise<void> {
|
||||||
|
const applicable = rec.deleted !== true && !rec.mergedIntoKey && rec.kind !== "listen";
|
||||||
|
let recordId = pendingRow?.id ?? null;
|
||||||
|
let workId: string | null = null;
|
||||||
|
let items: ListItemValue[] = [];
|
||||||
|
if (applicable && (rec.kind === "entry" || rec.kind === "engagement")) {
|
||||||
|
if (pendingRow?.target_entity === "work" && pendingRow.target_id) {
|
||||||
|
workId = pendingRow.target_id;
|
||||||
|
} else {
|
||||||
|
// Matching runs in its own transaction (it retries on unique violations). Its result is committed
|
||||||
|
// right away, so a failed apply is retried against the same work instead of a fresh draft.
|
||||||
|
workId = (await matchWork(st.db, st.userId, rec.work, st.actor)).workId;
|
||||||
|
recordId = await persistMatched(st, rec, workId, recordId);
|
||||||
|
}
|
||||||
|
} else if (applicable && rec.kind === "list") {
|
||||||
|
items = await matchListItems(st, rec.list.items);
|
||||||
|
}
|
||||||
|
|
||||||
|
await st.db.k.transaction().execute(async (tx) => {
|
||||||
|
const now = nowIso();
|
||||||
|
const row = {
|
||||||
|
current_hash: hash,
|
||||||
|
last_seen: now,
|
||||||
|
deleted_at: rec.deleted === true ? now : null,
|
||||||
|
merged_into_key: rec.mergedIntoKey ?? null,
|
||||||
|
target_entity: rec.kind === "listen" ? null : rec.kind,
|
||||||
|
target_id: null,
|
||||||
|
};
|
||||||
|
if (recordId) {
|
||||||
|
await tx.updateTable("source_records").set(row).where("id", "=", recordId).execute();
|
||||||
|
} else {
|
||||||
|
recordId = newId();
|
||||||
|
await tx
|
||||||
|
.insertInto("source_records")
|
||||||
|
.values({ id: recordId, connection_id: st.connectionId, key: rec.key, kind: rec.kind, first_seen: now, ...row })
|
||||||
|
.execute();
|
||||||
|
}
|
||||||
|
const id = recordId;
|
||||||
|
await tx.insertInto("source_record_versions").values({ id: newId(), record_id: id, hash, raw_gz: encodeVersion(rec), seen_at: now }).execute();
|
||||||
|
if (!applicable) return;
|
||||||
|
let targetId: string | null = null;
|
||||||
|
if (rec.kind === "entry" && workId) targetId = await addEntryRecord(tx, st, id, rec.key, workId, entryPatchFrom(rec.entry));
|
||||||
|
else if (rec.kind === "engagement" && workId) targetId = await addEngagementRecord(tx, st, id, rec.key, workId, engagementPatchFrom(rec.engagement));
|
||||||
|
else if (rec.kind === "list") targetId = await addListRecord(tx, st, id, rec.key, rec.list.name, items);
|
||||||
|
if (targetId) {
|
||||||
|
await tx.updateTable("source_records").set({ target_id: targetId }).where("id", "=", id).execute();
|
||||||
|
st.counts.added++;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Commit the record row with its matched work (`target_entity "work"`, pending hash) before applying. */
|
||||||
|
async function persistMatched(st: SyncState, rec: SourceRecord, workId: string, recordId: string | null): Promise<string> {
|
||||||
|
const now = nowIso();
|
||||||
|
if (recordId) {
|
||||||
|
await st.db.k.updateTable("source_records").set({ target_entity: "work", target_id: workId, last_seen: now }).where("id", "=", recordId).execute();
|
||||||
|
return recordId;
|
||||||
|
}
|
||||||
|
const id = newId();
|
||||||
|
await st.db.k
|
||||||
|
.insertInto("source_records")
|
||||||
|
.values({
|
||||||
|
id,
|
||||||
|
connection_id: st.connectionId,
|
||||||
|
key: rec.key,
|
||||||
|
kind: rec.kind,
|
||||||
|
current_hash: PENDING_HASH,
|
||||||
|
first_seen: now,
|
||||||
|
last_seen: now,
|
||||||
|
deleted_at: null,
|
||||||
|
merged_into_key: null,
|
||||||
|
target_entity: "work",
|
||||||
|
target_id: workId,
|
||||||
|
})
|
||||||
|
.execute();
|
||||||
|
return id;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function addEntryRecord(k: Executor, st: SyncState, recordId: string, key: string, workId: string, incoming: Fields): Promise<string | null> {
|
||||||
|
const live = await liveEntryFor(k, st, workId, incoming);
|
||||||
|
if (!live) {
|
||||||
|
st.log.push(`record ${key}: the user deleted this entry; not revived`);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
let applied = Object.keys(incoming);
|
||||||
|
if (!live.created) {
|
||||||
|
// The user (or another source) already has this entry: decide field by field.
|
||||||
|
const t: Target = { entity: "entry", id: live.row.id, current: entryValues(live.row), fieldSources: fromJson(live.row.field_sources, {}) };
|
||||||
|
const { patch } = await decideFields(k, st, recordId, t, incoming);
|
||||||
|
await applyPatch(k, st.db, st.userId, t, patch, st.actor);
|
||||||
|
applied = Object.keys(patch);
|
||||||
|
}
|
||||||
|
await writeEvent(k, st, {
|
||||||
|
recordId,
|
||||||
|
type: "added",
|
||||||
|
status: "auto_applied",
|
||||||
|
targetEntity: "entry",
|
||||||
|
targetId: live.row.id,
|
||||||
|
payload: { key, kind: "entry", fields: applied },
|
||||||
|
});
|
||||||
|
return live.row.id;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function addEngagementRecord(k: Executor, st: SyncState, recordId: string, key: string, workId: string, incoming: Fields): Promise<string | null> {
|
||||||
|
const live = await liveEntryFor(k, st, workId, {});
|
||||||
|
if (!live) {
|
||||||
|
st.log.push(`record ${key}: the user deleted this entry; engagement not added`);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const dup = await findDuplicateEngagement(k, st, live.row.id, incoming);
|
||||||
|
let engagementId: string;
|
||||||
|
let payload: Fields;
|
||||||
|
if (dup) {
|
||||||
|
// Same play from another source: attach. Fields nobody has set yet are filled; any other differing
|
||||||
|
// field goes through the per-field rules (conflict / pending changed). The date that identified the
|
||||||
|
// play differs only within the dedupe tolerance and is left alone.
|
||||||
|
const t: Target = {
|
||||||
|
entity: "engagement",
|
||||||
|
id: dup.row.id,
|
||||||
|
current: engagementValues(dup.row),
|
||||||
|
fieldSources: fromJson(dup.row.field_sources, {}),
|
||||||
|
};
|
||||||
|
const fill: Fields = {};
|
||||||
|
const rest: Fields = {};
|
||||||
|
for (const [f, v] of Object.entries(incoming)) {
|
||||||
|
if (f === dup.matchedOn) continue;
|
||||||
|
if (t.fieldSources[f] === undefined && isEmptyValue(t.current[f]) && !isEmptyValue(v)) fill[f] = v;
|
||||||
|
else rest[f] = v;
|
||||||
|
}
|
||||||
|
const { patch } = await decideFields(k, st, recordId, t, rest);
|
||||||
|
await applyPatch(k, st.db, st.userId, t, { ...fill, ...patch }, st.actor);
|
||||||
|
engagementId = dup.row.id;
|
||||||
|
payload = { key, kind: "engagement", fields: Object.keys({ ...fill, ...patch }), dedupedInto: dup.row.id };
|
||||||
|
} else {
|
||||||
|
const eg = await addEngagement(st.db, st.userId, live.row.id, incoming as Partial<EngagementPatch>, st.actor, k);
|
||||||
|
engagementId = eg.id;
|
||||||
|
payload = { key, kind: "engagement", fields: Object.keys(incoming) };
|
||||||
|
}
|
||||||
|
await k
|
||||||
|
.insertInto("engagement_sources")
|
||||||
|
.values({ id: newId(), engagement_id: engagementId, connection_id: st.connectionId, record_key: key })
|
||||||
|
.onConflict((oc) => oc.columns(["connection_id", "record_key"]).doNothing())
|
||||||
|
.execute();
|
||||||
|
await writeEvent(k, st, { recordId, type: "added", status: "auto_applied", targetEntity: "engagement", targetId: engagementId, payload });
|
||||||
|
return engagementId;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function addListRecord(k: Executor, st: SyncState, recordId: string, key: string, name: string, items: ListItemValue[]): Promise<string> {
|
||||||
|
const list = await createList(st.db, st.userId, { name, description: `Imported from ${st.pluginId}`, kind: "list" }, st.actor, k);
|
||||||
|
if (items.length > 0) await replaceListItems(st.db, st.userId, list.id, items, st.actor, k);
|
||||||
|
await writeEvent(k, st, {
|
||||||
|
recordId,
|
||||||
|
type: "added",
|
||||||
|
status: "auto_applied",
|
||||||
|
targetEntity: "list",
|
||||||
|
targetId: list.id,
|
||||||
|
payload: { key, kind: "list", fields: ["name", "description", "items"] },
|
||||||
|
});
|
||||||
|
return list.id;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function changeRecord(st: SyncState, existing: SourceRecordRow, rec: SourceRecord, hash: string): Promise<void> {
|
||||||
|
const prevRow = await st.db.k
|
||||||
|
.selectFrom("source_record_versions")
|
||||||
|
.select("raw_gz")
|
||||||
|
.where("record_id", "=", existing.id)
|
||||||
|
.where("hash", "=", existing.current_hash)
|
||||||
|
.orderBy("seen_at", "desc")
|
||||||
|
.orderBy("id", "desc")
|
||||||
|
.executeTakeFirst();
|
||||||
|
const prev = prevRow ? decodeVersion(prevRow.raw_gz).normalized : undefined;
|
||||||
|
const next = normalizeRecord(rec);
|
||||||
|
// List items are matched before the transaction (matchWork has its own), and only when they changed.
|
||||||
|
const itemsChanged = rec.kind === "list" && !rec.deleted && !sameValue(prev?.list?.items, next.list?.items);
|
||||||
|
const items = itemsChanged && rec.kind === "list" ? await matchListItems(st, rec.list.items) : null;
|
||||||
|
|
||||||
|
await st.db.k.transaction().execute(async (tx) => {
|
||||||
|
const now = nowIso();
|
||||||
|
// A merged record that is later reported deleted stays "merged": its disappearance is expected.
|
||||||
|
const mergedInto = rec.mergedIntoKey ?? (rec.deleted === true ? existing.merged_into_key : null);
|
||||||
|
await tx.insertInto("source_record_versions").values({ id: newId(), record_id: existing.id, hash, raw_gz: encodeVersion(rec), seen_at: now }).execute();
|
||||||
|
await tx
|
||||||
|
.updateTable("source_records")
|
||||||
|
.set({ current_hash: hash, last_seen: now, merged_into_key: mergedInto })
|
||||||
|
.where("id", "=", existing.id)
|
||||||
|
.execute();
|
||||||
|
|
||||||
|
if (rec.deleted === true) {
|
||||||
|
if (existing.deleted_at === null) await markRemoved(tx, st, { ...existing, merged_into_key: mergedInto });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
st.counts.changed++;
|
||||||
|
if (existing.deleted_at !== null) await reappeared(tx, existing);
|
||||||
|
|
||||||
|
if (rec.mergedIntoKey && rec.mergedIntoKey !== (prev?.mergedIntoKey ?? null)) await fileMerged(tx, st, existing, rec.mergedIntoKey);
|
||||||
|
|
||||||
|
const target = await loadTarget(tx, st, existing.target_entity, existing.target_id);
|
||||||
|
if (!target) return; // unmapped, or the user deleted it: never revived
|
||||||
|
const before = patchOf(prev);
|
||||||
|
const after = patchOf(next);
|
||||||
|
const incoming: Fields = {};
|
||||||
|
for (const [f, v] of Object.entries(after)) if (!sameValue(before[f], v)) incoming[f] = v;
|
||||||
|
if (items) incoming.items = items;
|
||||||
|
const { patch, from } = await decideFields(tx, st, existing.id, target, incoming);
|
||||||
|
await applyPatch(tx, st.db, st.userId, target, patch, st.actor);
|
||||||
|
for (const [field, to] of Object.entries(patch)) {
|
||||||
|
await writeEvent(tx, st, {
|
||||||
|
recordId: existing.id,
|
||||||
|
type: "changed",
|
||||||
|
status: "auto_applied",
|
||||||
|
targetEntity: target.entity,
|
||||||
|
targetId: target.id,
|
||||||
|
payload: { field, from: from[field], to },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
// The source's description of the work changed: refresh the (shared) work it maps to; never re-match.
|
||||||
|
if ((rec.kind === "entry" || rec.kind === "engagement") && prev?.work !== undefined && !sameValue(prev.work, next.work)) {
|
||||||
|
const workId = await targetWorkId(tx, target);
|
||||||
|
if (workId) await updateWorkFromHintIn(tx, workId, rec.work, st.actor);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function targetWorkId(k: Executor, t: Target): Promise<string | null> {
|
||||||
|
if (t.entity === "list") return null;
|
||||||
|
const entryId =
|
||||||
|
t.entity === "entry"
|
||||||
|
? t.id
|
||||||
|
: (await k.selectFrom("engagements").select("entry_id").where("id", "=", t.id).executeTakeFirst())?.entry_id;
|
||||||
|
if (!entryId) return null;
|
||||||
|
return (await k.selectFrom("entries").select("work_id").where("id", "=", entryId).executeTakeFirst())?.work_id ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The record is back at the source: clear `deleted_at` and retire still-pending removal events for it. */
|
||||||
|
async function reappeared(k: Executor, rec: SourceRecordRow): Promise<void> {
|
||||||
|
await k.updateTable("source_records").set({ deleted_at: null }).where("id", "=", rec.id).execute();
|
||||||
|
await k
|
||||||
|
.updateTable("change_events")
|
||||||
|
.set({ status: "ignored", resolved_at: nowIso() })
|
||||||
|
.where("record_id", "=", rec.id)
|
||||||
|
.where("type", "=", "removed")
|
||||||
|
.where("status", "=", "pending")
|
||||||
|
.execute();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Set `source_records.deleted_at` and file a pending `removed` event when there is a live target.
|
||||||
|
* A record merged into another (its `merged_into_key` set, or a pending `merged` event) is expected
|
||||||
|
* to vanish: no removal is counted or filed for it, ever.
|
||||||
|
*/
|
||||||
|
export async function markRemoved(k: Executor, st: SyncState, rec: SourceRecordRow): Promise<void> {
|
||||||
|
await k.updateTable("source_records").set({ deleted_at: nowIso() }).where("id", "=", rec.id).execute();
|
||||||
|
if (rec.merged_into_key) return;
|
||||||
|
const pendingMerge = await k
|
||||||
|
.selectFrom("change_events")
|
||||||
|
.select("id")
|
||||||
|
.where("record_id", "=", rec.id)
|
||||||
|
.where("type", "=", "merged")
|
||||||
|
.where("status", "=", "pending")
|
||||||
|
.executeTakeFirst();
|
||||||
|
if (pendingMerge) return;
|
||||||
|
st.counts.removed++;
|
||||||
|
const target = await loadTarget(k, st, rec.target_entity, rec.target_id);
|
||||||
|
if (!target) return;
|
||||||
|
const already = await k
|
||||||
|
.selectFrom("change_events")
|
||||||
|
.select("id")
|
||||||
|
.where("record_id", "=", rec.id)
|
||||||
|
.where("type", "=", "removed")
|
||||||
|
.where("status", "=", "pending")
|
||||||
|
.executeTakeFirst();
|
||||||
|
if (already) return;
|
||||||
|
await writeEvent(k, st, {
|
||||||
|
recordId: rec.id,
|
||||||
|
type: "removed",
|
||||||
|
status: "pending",
|
||||||
|
targetEntity: target.entity,
|
||||||
|
targetId: target.id,
|
||||||
|
payload: { key: rec.key, kind: rec.kind },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A pending `merged` event on the entry this record maps to (an engagement record's entry). */
|
||||||
|
async function fileMerged(k: Executor, st: SyncState, rec: SourceRecordRow, intoKey: string): Promise<void> {
|
||||||
|
st.counts.merged++;
|
||||||
|
const target = await loadTarget(k, st, rec.target_entity, rec.target_id);
|
||||||
|
if (!target || target.entity === "list") return;
|
||||||
|
const entryId =
|
||||||
|
target.entity === "entry"
|
||||||
|
? target.id
|
||||||
|
: (await k.selectFrom("engagements").select("entry_id").where("id", "=", target.id).executeTakeFirstOrThrow()).entry_id;
|
||||||
|
await writeEvent(k, st, {
|
||||||
|
recordId: rec.id,
|
||||||
|
type: "merged",
|
||||||
|
status: "pending",
|
||||||
|
targetEntity: "entry",
|
||||||
|
targetId: entryId,
|
||||||
|
payload: { key: rec.key, intoKey },
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,257 @@
|
|||||||
|
import type { Db } from "@sttracker/db/db";
|
||||||
|
import { nowIso } from "@sttracker/db/ids";
|
||||||
|
import { fromJson, toJson } from "@sttracker/db/json";
|
||||||
|
import type { ChangeStatus, ChangeType, Database } from "@sttracker/db/schema";
|
||||||
|
import type { WorkHint } from "@sttracker/plugin-sdk/types";
|
||||||
|
import { type EngagementPatch, softDeleteEngagement, updateEngagement } from "../engagements.ts";
|
||||||
|
import { type EntryPatch, moveEntryToWork, softDeleteEntry, upsertEntry } from "../entries.ts";
|
||||||
|
import { NotFoundError, ValidationError } from "../errors.ts";
|
||||||
|
import type { Actor, Executor } from "../history.ts";
|
||||||
|
import { softDeleteList } from "../lists.ts";
|
||||||
|
import { matchWork } from "../matching.ts";
|
||||||
|
import { resolveAliasIn } from "../works.ts";
|
||||||
|
import { applyPatch, engagementValues, entryValues } from "./apply.ts";
|
||||||
|
import { decodeVersion } from "./snapshot.ts";
|
||||||
|
|
||||||
|
export interface ChangeEvent {
|
||||||
|
id: string;
|
||||||
|
type: ChangeType;
|
||||||
|
status: ChangeStatus;
|
||||||
|
connectionId: string | null;
|
||||||
|
runId: string | null;
|
||||||
|
recordId: string | null;
|
||||||
|
targetEntity: string;
|
||||||
|
targetId: string | null;
|
||||||
|
payload: Record<string, unknown>;
|
||||||
|
createdAt: string;
|
||||||
|
resolvedAt: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function toChange(r: Database["change_events"]): ChangeEvent {
|
||||||
|
return {
|
||||||
|
id: r.id,
|
||||||
|
type: r.type,
|
||||||
|
status: r.status,
|
||||||
|
connectionId: r.connection_id,
|
||||||
|
runId: r.run_id,
|
||||||
|
recordId: r.record_id,
|
||||||
|
targetEntity: r.target_entity,
|
||||||
|
targetId: r.target_id,
|
||||||
|
payload: fromJson<Record<string, unknown>>(r.payload, {}),
|
||||||
|
createdAt: r.created_at,
|
||||||
|
resolvedAt: r.resolved_at,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The user's change events, newest first. `status` defaults to "pending" (the inbox); "all" includes
|
||||||
|
* auto-applied and resolved ones. `cursor` is the `nextCursor` of the previous page.
|
||||||
|
*/
|
||||||
|
export async function listChanges(
|
||||||
|
db: Db,
|
||||||
|
userId: string,
|
||||||
|
opts: { status?: "pending" | "all"; connectionId?: string; limit?: number; cursor?: string } = {},
|
||||||
|
): Promise<{ items: ChangeEvent[]; nextCursor: string | null }> {
|
||||||
|
const limit = Math.min(Math.max(opts.limit ?? 50, 1), 200);
|
||||||
|
let q = db.k.selectFrom("change_events").selectAll().where("user_id", "=", userId);
|
||||||
|
if ((opts.status ?? "pending") === "pending") q = q.where("status", "=", "pending");
|
||||||
|
if (opts.connectionId) q = q.where("connection_id", "=", opts.connectionId);
|
||||||
|
if (opts.cursor) q = q.where("id", "<", opts.cursor);
|
||||||
|
const rows = await q.orderBy("id", "desc").limit(limit + 1).execute();
|
||||||
|
const items = rows.slice(0, limit).map(toChange);
|
||||||
|
return { items, nextCursor: rows.length > limit ? items[items.length - 1]!.id : null };
|
||||||
|
}
|
||||||
|
|
||||||
|
const STATUS_FOR = { accept: "accepted", ignore: "ignored", keep_mine: "kept_mine" } as const;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolve a pending event.
|
||||||
|
* - `accept`: changed/conflict applies the incoming value as the event's plugin actor (which then owns
|
||||||
|
* the field); removed soft-deletes the target (an engagement other sources still report only loses
|
||||||
|
* this connection's `engagement_sources` row); merged moves the entry to the merge target's work.
|
||||||
|
* - `keep_mine`: changed/conflict set `field_sources[field] = "user"`, so that source stops overriding it.
|
||||||
|
* - `ignore`: data untouched.
|
||||||
|
* All three set `status` and `resolved_at`. Accept/keep_mine on a target that is gone (deleted, or merged
|
||||||
|
* away with no live survivor) resolves the event as `ignored` with `payload.reason = "target_gone"`.
|
||||||
|
*/
|
||||||
|
export async function resolveChange(
|
||||||
|
db: Db,
|
||||||
|
userId: string,
|
||||||
|
changeId: string,
|
||||||
|
action: "accept" | "ignore" | "keep_mine",
|
||||||
|
): Promise<void> {
|
||||||
|
if (!Object.hasOwn(STATUS_FOR, action)) throw new ValidationError("action", "invalid");
|
||||||
|
const row = await db.k.selectFrom("change_events").selectAll().where("id", "=", changeId).where("user_id", "=", userId).executeTakeFirst();
|
||||||
|
if (!row) throw new NotFoundError("change");
|
||||||
|
if (row.status !== "pending") throw new ValidationError("status", "not_pending");
|
||||||
|
const ev = toChange(row);
|
||||||
|
const actor = await actorFor(db, ev.connectionId);
|
||||||
|
|
||||||
|
// matchWork runs its own transaction; resolve the merge target before opening ours.
|
||||||
|
const mergeTarget = action === "accept" && ev.type === "merged" ? await mergeTargetWork(db, userId, ev, actor) : null;
|
||||||
|
|
||||||
|
await db.k.transaction().execute(async (tx) => {
|
||||||
|
// Claim the event first so a concurrent resolve of the same event becomes a no-op failure.
|
||||||
|
const claimed = await tx
|
||||||
|
.updateTable("change_events")
|
||||||
|
.set({ status: STATUS_FOR[action], resolved_at: nowIso() })
|
||||||
|
.where("id", "=", ev.id)
|
||||||
|
.where("status", "=", "pending")
|
||||||
|
.executeTakeFirst();
|
||||||
|
if (Number(claimed.numUpdatedRows) === 0) throw new ValidationError("status", "not_pending");
|
||||||
|
if (action === "ignore") return;
|
||||||
|
const isField = ev.type === "changed" || ev.type === "conflict";
|
||||||
|
if (action === "keep_mine" && !isField) return;
|
||||||
|
|
||||||
|
const targetId = await liveTargetId(tx, userId, ev);
|
||||||
|
if (!targetId) {
|
||||||
|
await tx
|
||||||
|
.updateTable("change_events")
|
||||||
|
.set({ status: "ignored", payload: toJson({ ...ev.payload, reason: "target_gone" }) })
|
||||||
|
.where("id", "=", ev.id)
|
||||||
|
.execute();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (isField) {
|
||||||
|
const field = String(ev.payload.field);
|
||||||
|
const value = action === "keep_mine" ? undefined : ev.type === "conflict" ? ev.payload.incoming : ev.payload.to;
|
||||||
|
await setField(db, tx, userId, ev.targetEntity, targetId, field, value, action === "keep_mine" ? "user" : actor);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (ev.type === "removed") return removeTarget(db, tx, userId, ev, targetId, actor);
|
||||||
|
if (ev.type === "merged" && mergeTarget) return moveTo(db, tx, userId, targetId, mergeTarget, actor);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function actorFor(db: Db, connectionId: string | null): Promise<Actor> {
|
||||||
|
if (!connectionId) return "user";
|
||||||
|
const c = await db.k.selectFrom("source_connections").select(["id", "plugin_id"]).where("id", "=", connectionId).executeTakeFirst();
|
||||||
|
return c ? `plugin:${c.plugin_id}:${c.id}` : "user";
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The event's target if it is still live. A soft-deleted entry that was merged into another (the user's
|
||||||
|
* work alias leads to a work with a live entry) resolves to that survivor. Otherwise null.
|
||||||
|
*/
|
||||||
|
async function liveTargetId(k: Executor, userId: string, ev: ChangeEvent): Promise<string | null> {
|
||||||
|
if (!ev.targetId) return null;
|
||||||
|
if (ev.targetEntity === "entry") {
|
||||||
|
const r = await k.selectFrom("entries").select(["id", "work_id", "deleted_at"]).where("id", "=", ev.targetId).where("user_id", "=", userId).executeTakeFirst();
|
||||||
|
if (!r) return null;
|
||||||
|
if (r.deleted_at === null) return r.id;
|
||||||
|
const to = await resolveAliasIn(k, userId, r.work_id);
|
||||||
|
if (to === r.work_id) return null;
|
||||||
|
const survivor = await k
|
||||||
|
.selectFrom("entries")
|
||||||
|
.select("id")
|
||||||
|
.where("user_id", "=", userId)
|
||||||
|
.where("work_id", "=", to)
|
||||||
|
.where("deleted_at", "is", null)
|
||||||
|
.executeTakeFirst();
|
||||||
|
return survivor?.id ?? null;
|
||||||
|
}
|
||||||
|
if (ev.targetEntity === "engagement") {
|
||||||
|
const r = await k
|
||||||
|
.selectFrom("engagements as g")
|
||||||
|
.innerJoin("entries as e", "e.id", "g.entry_id")
|
||||||
|
.select("g.id")
|
||||||
|
.where("g.id", "=", ev.targetId)
|
||||||
|
.where("e.user_id", "=", userId)
|
||||||
|
.where("g.deleted_at", "is", null)
|
||||||
|
.where("e.deleted_at", "is", null)
|
||||||
|
.executeTakeFirst();
|
||||||
|
return r?.id ?? null;
|
||||||
|
}
|
||||||
|
if (ev.targetEntity === "list") {
|
||||||
|
const r = await k
|
||||||
|
.selectFrom("lists")
|
||||||
|
.select("id")
|
||||||
|
.where("id", "=", ev.targetId)
|
||||||
|
.where("user_id", "=", userId)
|
||||||
|
.where("deleted_at", "is", null)
|
||||||
|
.executeTakeFirst();
|
||||||
|
return r?.id ?? null;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Write `value` to a live target's field as `actor`; with `value` undefined, re-stamp the current value. */
|
||||||
|
async function setField(db: Db, k: Executor, userId: string, entity: string, id: string, field: string, value: unknown, actor: Actor): Promise<void> {
|
||||||
|
if (entity === "entry") {
|
||||||
|
const r = await k.selectFrom("entries").selectAll().where("id", "=", id).executeTakeFirstOrThrow();
|
||||||
|
const v = value === undefined ? entryValues(r)[field] : value;
|
||||||
|
await upsertEntry(db, userId, r.work_id, { [field]: v } as Partial<EntryPatch>, actor, k);
|
||||||
|
} else if (entity === "engagement") {
|
||||||
|
const r = await k.selectFrom("engagements").selectAll().where("id", "=", id).executeTakeFirstOrThrow();
|
||||||
|
const v = value === undefined ? engagementValues(r)[field] : value;
|
||||||
|
await updateEngagement(db, userId, id, { [field]: v } as Partial<EngagementPatch>, actor, k);
|
||||||
|
} else if (entity === "list") {
|
||||||
|
// Lists have no field_sources: ownership comes from history, so keep_mine needs no write.
|
||||||
|
if (value === undefined) return;
|
||||||
|
await applyPatch(k, db, userId, { entity: "list", id }, { [field]: value }, actor);
|
||||||
|
} else {
|
||||||
|
throw new ValidationError("targetEntity", "unsupported");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function removeTarget(db: Db, k: Executor, userId: string, ev: ChangeEvent, targetId: string, actor: Actor): Promise<void> {
|
||||||
|
if (ev.targetEntity === "entry") return softDeleteEntry(db, userId, targetId, actor, k);
|
||||||
|
if (ev.targetEntity === "list") return softDeleteList(db, userId, targetId, actor, k);
|
||||||
|
if (ev.targetEntity === "engagement") {
|
||||||
|
// The same play may still be reported by other sources: detach this one, delete only when none remain.
|
||||||
|
if (ev.connectionId) {
|
||||||
|
await k.deleteFrom("engagement_sources").where("engagement_id", "=", targetId).where("connection_id", "=", ev.connectionId).execute();
|
||||||
|
}
|
||||||
|
const left = await k.selectFrom("engagement_sources").select("id").where("engagement_id", "=", targetId).executeTakeFirst();
|
||||||
|
if (left) return;
|
||||||
|
await softDeleteEngagement(db, userId, targetId, actor, k);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The work a merged event moves its entry to. An admin merge carries `toWorkId`. A source merge names
|
||||||
|
* the target record (`intoKey`): its mapped entry's work when it has one, else the work matched for
|
||||||
|
* the target record's latest hint.
|
||||||
|
*/
|
||||||
|
async function mergeTargetWork(db: Db, userId: string, ev: ChangeEvent, actor: Actor): Promise<string> {
|
||||||
|
if (typeof ev.payload.toWorkId === "string") return ev.payload.toWorkId;
|
||||||
|
const intoKey = ev.payload.intoKey;
|
||||||
|
if (!ev.connectionId || typeof intoKey !== "string") throw new ValidationError("payload", "no_merge_target");
|
||||||
|
const target = await db.k
|
||||||
|
.selectFrom("source_records")
|
||||||
|
.selectAll()
|
||||||
|
.where("connection_id", "=", ev.connectionId)
|
||||||
|
.where("key", "=", intoKey)
|
||||||
|
.executeTakeFirst();
|
||||||
|
if (!target) throw new ValidationError("intoKey", "unknown_record");
|
||||||
|
// A record matched but not yet applied stores its work directly.
|
||||||
|
if (target.target_entity === "work" && target.target_id) return target.target_id;
|
||||||
|
if (target.target_id && (target.target_entity === "entry" || target.target_entity === "engagement")) {
|
||||||
|
const entryId =
|
||||||
|
target.target_entity === "entry"
|
||||||
|
? target.target_id
|
||||||
|
: (await db.k.selectFrom("engagements").select("entry_id").where("id", "=", target.target_id).executeTakeFirst())?.entry_id;
|
||||||
|
const entry = entryId
|
||||||
|
? await db.k.selectFrom("entries").select("work_id").where("id", "=", entryId).where("user_id", "=", userId).executeTakeFirst()
|
||||||
|
: undefined;
|
||||||
|
if (entry) return entry.work_id;
|
||||||
|
}
|
||||||
|
const version = await db.k
|
||||||
|
.selectFrom("source_record_versions")
|
||||||
|
.select("raw_gz")
|
||||||
|
.where("record_id", "=", target.id)
|
||||||
|
.where("hash", "=", target.current_hash)
|
||||||
|
.orderBy("seen_at", "desc")
|
||||||
|
.orderBy("id", "desc")
|
||||||
|
.executeTakeFirst();
|
||||||
|
const hint = version ? (decodeVersion(version.raw_gz).normalized.work as WorkHint | undefined) : undefined;
|
||||||
|
if (!hint) throw new ValidationError("intoKey", "no_work_hint");
|
||||||
|
return (await matchWork(db, userId, hint, actor)).workId;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Move the live entry to the work; `moveEntryToWork` re-points source records if it merges. */
|
||||||
|
async function moveTo(db: Db, k: Executor, userId: string, entryId: string, workId: string, actor: Actor): Promise<void> {
|
||||||
|
const entry = await k.selectFrom("entries").select("work_id").where("id", "=", entryId).executeTakeFirstOrThrow();
|
||||||
|
if (entry.work_id === workId) return;
|
||||||
|
await moveEntryToWork(db, userId, entryId, workId, actor, k);
|
||||||
|
}
|
||||||
@@ -0,0 +1,95 @@
|
|||||||
|
import type { Db } from "@sttracker/db/db";
|
||||||
|
import { newId, nowIso } from "@sttracker/db/ids";
|
||||||
|
import { toJson } from "@sttracker/db/json";
|
||||||
|
import type { ListenFields, SourceRecord } from "@sttracker/plugin-sdk/types";
|
||||||
|
import type { Actor } from "../history.ts";
|
||||||
|
import { isUniqueViolation } from "../matching.ts";
|
||||||
|
import { createWork, findWorkByExternalIds } from "../works.ts";
|
||||||
|
|
||||||
|
/** The one external-ID namespace the engine names itself (spec §6.4). */
|
||||||
|
export const RECORDING_NAMESPACE = "musicbrainz_recording";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The shared track work for a MusicBrainz recording: an existing work carrying the id, else a new
|
||||||
|
* shared `track` work. Never renames an existing work, and never creates drafts.
|
||||||
|
*/
|
||||||
|
async function trackWork(db: Db, recording: string, l: ListenFields, actor: Actor): Promise<string> {
|
||||||
|
const ids = { [RECORDING_NAMESPACE]: recording };
|
||||||
|
for (let attempt = 1; ; attempt++) {
|
||||||
|
const found = await findWorkByExternalIds(db, ids);
|
||||||
|
if (found) return found;
|
||||||
|
try {
|
||||||
|
return await createWork(
|
||||||
|
db,
|
||||||
|
{ hint: { title: l.track.trim() || "Untitled track", mediaType: "track", externalIds: ids, creators: l.artist ? [{ name: l.artist }] : [] } },
|
||||||
|
actor,
|
||||||
|
);
|
||||||
|
} catch (err) {
|
||||||
|
// A concurrent ingest created it first: look again.
|
||||||
|
if (attempt >= 3 || !isUniqueViolation(err)) throw err;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Listens per query/transaction. 500 rows × 12 columns stays well under both dialects' parameter limits. */
|
||||||
|
const BATCH = 500;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Append listens (spec §6.4): insert-or-ignore on (user, source, source_key), where `source_key` is the
|
||||||
|
* record key. A work is linked only through `mbids.recording`. Non-listen records are ignored.
|
||||||
|
* No change events are written. Per batch: one lookup of existing keys, each distinct recording resolved
|
||||||
|
* once (outside the transaction; `createWork` has its own), then one multi-row insert.
|
||||||
|
*/
|
||||||
|
export async function ingestListens(
|
||||||
|
db: Db,
|
||||||
|
userId: string,
|
||||||
|
source: string,
|
||||||
|
records: SourceRecord[],
|
||||||
|
actor: Actor = `plugin:${source}:ingest`,
|
||||||
|
): Promise<{ inserted: number }> {
|
||||||
|
const listens = records.filter((r): r is Extract<SourceRecord, { kind: "listen" }> => r.kind === "listen");
|
||||||
|
let inserted = 0;
|
||||||
|
for (let i = 0; i < listens.length; i += BATCH) {
|
||||||
|
const batch = new Map<string, ListenFields>();
|
||||||
|
for (const r of listens.slice(i, i + BATCH)) if (!batch.has(r.key)) batch.set(r.key, r.listen);
|
||||||
|
const existing = await db.k
|
||||||
|
.selectFrom("listens")
|
||||||
|
.select("source_key")
|
||||||
|
.where("user_id", "=", userId)
|
||||||
|
.where("source", "=", source)
|
||||||
|
.where("source_key", "in", [...batch.keys()])
|
||||||
|
.execute();
|
||||||
|
for (const e of existing) batch.delete(e.source_key);
|
||||||
|
if (batch.size === 0) continue;
|
||||||
|
|
||||||
|
const works = new Map<string, string>();
|
||||||
|
for (const l of batch.values()) {
|
||||||
|
const rec = l.mbids?.recording;
|
||||||
|
if (rec && !works.has(rec)) works.set(rec, await trackWork(db, rec, l, actor));
|
||||||
|
}
|
||||||
|
const now = nowIso();
|
||||||
|
const rows = [...batch].map(([key, l]) => ({
|
||||||
|
id: newId(),
|
||||||
|
user_id: userId,
|
||||||
|
listened_at: l.listenedAt,
|
||||||
|
artist: l.artist,
|
||||||
|
track: l.track,
|
||||||
|
album: l.album ?? null,
|
||||||
|
duration_ms: l.durationMs ?? null,
|
||||||
|
mbids: toJson(l.mbids ?? {}),
|
||||||
|
work_id: l.mbids?.recording ? works.get(l.mbids.recording)! : null,
|
||||||
|
source,
|
||||||
|
source_key: key,
|
||||||
|
created_at: now,
|
||||||
|
}));
|
||||||
|
const res = await db.k.transaction().execute((tx) =>
|
||||||
|
tx
|
||||||
|
.insertInto("listens")
|
||||||
|
.values(rows)
|
||||||
|
.onConflict((oc) => oc.columns(["user_id", "source", "source_key"]).doNothing())
|
||||||
|
.executeTakeFirst(),
|
||||||
|
);
|
||||||
|
inserted += Number(res.numInsertedOrUpdatedRows ?? 0n);
|
||||||
|
}
|
||||||
|
return { inserted };
|
||||||
|
}
|
||||||
@@ -0,0 +1,164 @@
|
|||||||
|
import type { Db } from "@sttracker/db/db";
|
||||||
|
import { newId, nowIso } from "@sttracker/db/ids";
|
||||||
|
import { fromJson, toJson } from "@sttracker/db/json";
|
||||||
|
import type { ConnectionStatus, Database, RunMode } from "@sttracker/db/schema";
|
||||||
|
import type { PluginContext, SourcePlugin, SourceRecord } from "@sttracker/plugin-sdk/types";
|
||||||
|
import { NotFoundError, ValidationError } from "../errors.ts";
|
||||||
|
import type { Actor } from "../history.ts";
|
||||||
|
import { applyRecord, committed, emptyCounts, markRemoved, type RunCounts, type SyncState } from "./apply.ts";
|
||||||
|
import { ingestListens } from "./listens.ts";
|
||||||
|
|
||||||
|
/** A `source_connections` row with JSON parsed. Credentials stay encrypted; the caller's `makeContext` decrypts. */
|
||||||
|
export interface Connection {
|
||||||
|
id: string;
|
||||||
|
userId: string;
|
||||||
|
pluginId: string;
|
||||||
|
name: string;
|
||||||
|
config: Record<string, unknown>;
|
||||||
|
credentialsEnc: string | null;
|
||||||
|
scheduleMinutes: number | null;
|
||||||
|
cursor: string | null;
|
||||||
|
status: ConnectionStatus;
|
||||||
|
lastRunAt: string | null;
|
||||||
|
createdAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SyncDeps {
|
||||||
|
db: Db;
|
||||||
|
/** Builds the plugin context (http client, credentials, cursor, logging). Injected by the caller. */
|
||||||
|
makeContext: (conn: Connection, runId: string) => PluginContext;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type SyncMode = "full" | "changes" | { file: { name: string; bytes: Uint8Array } };
|
||||||
|
|
||||||
|
export interface RunSummary {
|
||||||
|
runId: string;
|
||||||
|
status: "ok" | "failed";
|
||||||
|
counts: RunCounts;
|
||||||
|
error?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const LISTEN_BATCH = 500;
|
||||||
|
|
||||||
|
function toConnection(r: Database["source_connections"]): Connection {
|
||||||
|
return {
|
||||||
|
id: r.id,
|
||||||
|
userId: r.user_id,
|
||||||
|
pluginId: r.plugin_id,
|
||||||
|
name: r.name,
|
||||||
|
config: fromJson<Record<string, unknown>>(r.config, {}),
|
||||||
|
credentialsEnc: r.credentials_enc,
|
||||||
|
scheduleMinutes: r.schedule_minutes,
|
||||||
|
cursor: r.cursor,
|
||||||
|
status: r.status,
|
||||||
|
lastRunAt: r.last_run_at,
|
||||||
|
createdAt: r.created_at,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function iterate(plugin: SourcePlugin, ctx: PluginContext, mode: SyncMode): AsyncIterable<SourceRecord> {
|
||||||
|
const id = plugin.manifest.id;
|
||||||
|
if (mode === "full") {
|
||||||
|
if (!plugin.pull) throw new Error(`plugin "${id}" cannot pull`);
|
||||||
|
return plugin.pull(ctx);
|
||||||
|
}
|
||||||
|
if (mode === "changes") {
|
||||||
|
if (!plugin.pullChanges) throw new Error(`plugin "${id}" cannot pull changes`);
|
||||||
|
return plugin.pullChanges(ctx);
|
||||||
|
}
|
||||||
|
if (!plugin.importFile) throw new Error(`plugin "${id}" cannot import files`);
|
||||||
|
return plugin.importFile(ctx, mode.file);
|
||||||
|
}
|
||||||
|
|
||||||
|
const message = (e: unknown) => (e instanceof Error ? e.message : String(e));
|
||||||
|
|
||||||
|
/**
|
||||||
|
* One sync run (spec §8.3): pull records, snapshot and diff each against its previous version, apply
|
||||||
|
* or file change events, and append listens. Absent records are marked removed only after a `full`
|
||||||
|
* pull whose iteration finished and whose records all applied. Any failure marks the run `failed` and
|
||||||
|
* the connection `error`; the summary is returned, not thrown.
|
||||||
|
*/
|
||||||
|
export async function runSync(deps: SyncDeps, plugin: SourcePlugin, connectionId: string, mode: SyncMode): Promise<RunSummary> {
|
||||||
|
const { db } = deps;
|
||||||
|
const row = await db.k.selectFrom("source_connections").selectAll().where("id", "=", connectionId).executeTakeFirst();
|
||||||
|
if (!row) throw new NotFoundError("connection");
|
||||||
|
if (row.plugin_id !== plugin.manifest.id) throw new ValidationError("plugin", "connection_plugin_mismatch");
|
||||||
|
const conn = toConnection(row);
|
||||||
|
const runId = newId();
|
||||||
|
const runMode: RunMode = typeof mode === "string" ? mode : "file";
|
||||||
|
await db.k
|
||||||
|
.insertInto("source_runs")
|
||||||
|
.values({ id: runId, connection_id: conn.id, mode: runMode, started_at: nowIso(), finished_at: null, status: "running", counts: toJson(emptyCounts()), log: "", error: null })
|
||||||
|
.execute();
|
||||||
|
|
||||||
|
const actor: Actor = `plugin:${conn.pluginId}:${conn.id}`;
|
||||||
|
const st: SyncState = { db, userId: conn.userId, connectionId: conn.id, pluginId: conn.pluginId, runId, actor, counts: emptyCounts(), log: [] };
|
||||||
|
const seen = new Set<string>();
|
||||||
|
const errors: string[] = [];
|
||||||
|
let pullError: string | null = null;
|
||||||
|
let listens: SourceRecord[] = [];
|
||||||
|
const flushListens = async () => {
|
||||||
|
if (listens.length === 0) return;
|
||||||
|
const batch = listens;
|
||||||
|
listens = [];
|
||||||
|
try {
|
||||||
|
st.counts.listens += (await ingestListens(db, conn.userId, conn.pluginId, batch, actor)).inserted;
|
||||||
|
} catch (e) {
|
||||||
|
errors.push(`listens: ${message(e)}`);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
try {
|
||||||
|
const ctx = deps.makeContext(conn, runId);
|
||||||
|
for await (const rec of iterate(plugin, ctx, mode)) {
|
||||||
|
st.counts.seen++;
|
||||||
|
if (rec.kind === "listen") {
|
||||||
|
listens.push(rec);
|
||||||
|
if (listens.length >= LISTEN_BATCH) await flushListens();
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
seen.add(rec.key);
|
||||||
|
try {
|
||||||
|
await applyRecord(st, rec);
|
||||||
|
} catch (e) {
|
||||||
|
errors.push(`record ${rec.key}: ${message(e)}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
pullError = message(e);
|
||||||
|
}
|
||||||
|
await flushListens();
|
||||||
|
|
||||||
|
// Removal by absence: only a completed full pull with every record applied can prove a record is gone.
|
||||||
|
if (mode === "full" && pullError === null && errors.length === 0) {
|
||||||
|
try {
|
||||||
|
const live = await db.k
|
||||||
|
.selectFrom("source_records")
|
||||||
|
.selectAll()
|
||||||
|
.where("connection_id", "=", conn.id)
|
||||||
|
.where("deleted_at", "is", null)
|
||||||
|
.execute();
|
||||||
|
for (const r of live) {
|
||||||
|
if (seen.has(r.key)) continue;
|
||||||
|
await committed(st, (local) => db.k.transaction().execute((tx) => markRemoved(tx, local, r)));
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
errors.push(`removal: ${message(e)}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const failed = pullError !== null || errors.length > 0;
|
||||||
|
const error = pullError ?? (errors.length > 0 ? `${errors.length} error(s); first: ${errors[0]}` : undefined);
|
||||||
|
const log = [...st.log, ...errors];
|
||||||
|
if (pullError !== null) log.push(`pull failed: ${pullError}`);
|
||||||
|
const now = nowIso();
|
||||||
|
await db.k
|
||||||
|
.updateTable("source_runs")
|
||||||
|
.set({ finished_at: now, status: failed ? "failed" : "ok", counts: toJson(st.counts), log: log.join("\n"), error: error ?? null })
|
||||||
|
.where("id", "=", runId)
|
||||||
|
.execute();
|
||||||
|
const nextStatus: ConnectionStatus = failed ? "error" : conn.status === "error" ? "active" : conn.status;
|
||||||
|
await db.k.updateTable("source_connections").set({ last_run_at: now, status: nextStatus }).where("id", "=", conn.id).execute();
|
||||||
|
|
||||||
|
return { runId, status: failed ? "failed" : "ok", counts: st.counts, ...(error !== undefined ? { error } : {}) };
|
||||||
|
}
|
||||||
@@ -0,0 +1,75 @@
|
|||||||
|
import type { SourceRecord } from "@sttracker/plugin-sdk/types";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The part of a `SourceRecord` that the change engine compares: everything except `key` and `raw`.
|
||||||
|
* `deleted` is always a boolean and `mergedIntoKey` always present (null when unset), so an absent
|
||||||
|
* flag and an explicit `false` hash the same.
|
||||||
|
*/
|
||||||
|
export interface NormalizedRecord {
|
||||||
|
kind: SourceRecord["kind"];
|
||||||
|
work?: unknown;
|
||||||
|
entry?: Record<string, unknown>;
|
||||||
|
engagement?: Record<string, unknown>;
|
||||||
|
listen?: Record<string, unknown>;
|
||||||
|
list?: Record<string, unknown>;
|
||||||
|
deleted: boolean;
|
||||||
|
mergedIntoKey: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** JSON with object keys sorted at every depth; `undefined` members are dropped, as in `JSON.stringify`. */
|
||||||
|
export function stableStringify(v: unknown): string {
|
||||||
|
return JSON.stringify(sortKeys(v)) ?? "null";
|
||||||
|
}
|
||||||
|
|
||||||
|
function sortKeys(v: unknown): unknown {
|
||||||
|
if (Array.isArray(v)) return v.map((x) => (x === undefined ? null : sortKeys(x)));
|
||||||
|
if (v === null || typeof v !== "object") return v;
|
||||||
|
const out: Record<string, unknown> = {};
|
||||||
|
for (const k of Object.keys(v).sort()) {
|
||||||
|
const x = (v as Record<string, unknown>)[k];
|
||||||
|
if (x !== undefined) out[k] = sortKeys(x);
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** True when two values serialise identically (`undefined` counts as `null`). */
|
||||||
|
export function sameValue(a: unknown, b: unknown): boolean {
|
||||||
|
return stableStringify(a ?? null) === stableStringify(b ?? null);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function normalizeRecord(r: SourceRecord): NormalizedRecord {
|
||||||
|
const base = { kind: r.kind, deleted: r.deleted === true, mergedIntoKey: r.mergedIntoKey ?? null };
|
||||||
|
const plain = (o: unknown) => JSON.parse(stableStringify(o)) as Record<string, unknown>;
|
||||||
|
switch (r.kind) {
|
||||||
|
case "entry":
|
||||||
|
return { ...base, work: plain(r.work), entry: plain(r.entry) };
|
||||||
|
case "engagement":
|
||||||
|
return { ...base, work: plain(r.work), engagement: plain(r.engagement) };
|
||||||
|
case "listen":
|
||||||
|
return { ...base, listen: plain(r.listen) };
|
||||||
|
case "list":
|
||||||
|
return { ...base, list: plain(r.list) };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** sha256 (hex) of the stable JSON of the normalised record. The raw payload is not part of it. */
|
||||||
|
export function hashRecord(r: SourceRecord): string {
|
||||||
|
return new Bun.CryptoHasher("sha256").update(stableStringify(normalizeRecord(r))).digest("hex");
|
||||||
|
}
|
||||||
|
|
||||||
|
/** What a `source_record_versions.raw_gz` holds: the normalised record (for diffing) and the raw payload. */
|
||||||
|
export interface StoredVersion {
|
||||||
|
normalized: NormalizedRecord;
|
||||||
|
raw: unknown;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** gzip (`Bun.gzipSync`) + base64 of `{ normalized, raw }`. */
|
||||||
|
export function encodeVersion(r: SourceRecord): string {
|
||||||
|
const json = JSON.stringify({ normalized: normalizeRecord(r), raw: r.raw ?? null });
|
||||||
|
return Buffer.from(Bun.gzipSync(new TextEncoder().encode(json))).toString("base64");
|
||||||
|
}
|
||||||
|
|
||||||
|
export function decodeVersion(s: string): StoredVersion {
|
||||||
|
const bytes = Bun.gunzipSync(Buffer.from(s, "base64"));
|
||||||
|
return JSON.parse(new TextDecoder().decode(bytes)) as StoredVersion;
|
||||||
|
}
|
||||||
@@ -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();
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,140 @@
|
|||||||
|
import { expect, spyOn, test } from "bun:test";
|
||||||
|
import { mkdtemp, rm } from "node:fs/promises";
|
||||||
|
import { tmpdir } from "node:os";
|
||||||
|
import { join } from "node:path";
|
||||||
|
import { newId, nowIso } from "@sttracker/db/ids";
|
||||||
|
import { forEachDialect } from "@sttracker/db/testing";
|
||||||
|
import { definePlugin, type SourcePlugin } from "@sttracker/plugin-sdk/types";
|
||||||
|
import { listAttachmentLinks, readAttachment, requireLinkTarget, storeAttachment } from "../src/attachments.ts";
|
||||||
|
import { createAppToken, listAppTokens, revokeAppToken } from "../src/auth.ts";
|
||||||
|
import { createConnection, deleteConnection, devicePoll, deviceStart, getConnection, listRuns, syncHandler } from "../src/connections.ts";
|
||||||
|
import { listSuggestions, quickAdd } from "../src/entries.ts";
|
||||||
|
import { addEngagement } from "../src/engagements.ts";
|
||||||
|
import { NotFoundError, ValidationError } from "../src/errors.ts";
|
||||||
|
import type { PluginRegistry } from "../src/registry.ts";
|
||||||
|
import { loadPlugins } from "../src/registry.ts";
|
||||||
|
import { makeUser, makeWork } from "./user-layer-fixtures.ts";
|
||||||
|
|
||||||
|
const SECRET = "test-secret";
|
||||||
|
const warn = spyOn(console, "warn").mockImplementation(() => {});
|
||||||
|
const reg = await loadPlugins([join(import.meta.dir, "fixtures/plugins")]);
|
||||||
|
warn.mockRestore();
|
||||||
|
const echo = reg.get("echo")!;
|
||||||
|
|
||||||
|
const fileish: SourcePlugin = definePlugin({
|
||||||
|
manifest: { id: "fileish", name: "Fileish", version: "1", sdkVersion: 1, mediaTypes: ["book"], capabilities: ["importFile"], config: [] },
|
||||||
|
async *importFile(_ctx, file) {
|
||||||
|
yield {
|
||||||
|
kind: "entry",
|
||||||
|
key: file.name,
|
||||||
|
raw: new TextDecoder().decode(file.bytes),
|
||||||
|
work: { title: new TextDecoder().decode(file.bytes), mediaType: "book", externalIds: { isbn13: "9780000000009" } },
|
||||||
|
entry: { status: "planned" },
|
||||||
|
};
|
||||||
|
},
|
||||||
|
deviceAuth: {
|
||||||
|
start: async () => ({ userCode: "ABCD", verificationUrl: "https://x.test/device", deviceCode: "dc", intervalSec: 5, expiresInSec: 600 }),
|
||||||
|
poll: async (_ctx, deviceCode) => (deviceCode === "dc" ? { access_token: "tok" } : "pending"),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const reg2: PluginRegistry = { get: (id) => (id === "fileish" ? fileish : reg.get(id)), list: () => [...reg.list(), fileish.manifest], reload: async () => {} };
|
||||||
|
|
||||||
|
forEachDialect("api support", (getDb) => {
|
||||||
|
test("listAppTokens returns metadata only, revoked flagged, scoped to user", async () => {
|
||||||
|
const db = getDb();
|
||||||
|
const u = await makeUser(db);
|
||||||
|
const other = await makeUser(db, "bob");
|
||||||
|
const t = await createAppToken(db, u, "script", ["read"]);
|
||||||
|
await createAppToken(db, other, "x", ["write"]);
|
||||||
|
await revokeAppToken(db, u, t.id);
|
||||||
|
const list = await listAppTokens(db, u);
|
||||||
|
expect(list).toHaveLength(1);
|
||||||
|
expect(list[0]).toMatchObject({ id: t.id, name: "script", scopes: ["read"], lastUsedAt: null });
|
||||||
|
expect(list[0]!.revokedAt).not.toBeNull();
|
||||||
|
expect(JSON.stringify(list)).not.toContain("hash");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("listSuggestions lists pending suggestions for a visible work", async () => {
|
||||||
|
const db = getDb();
|
||||||
|
const u = await makeUser(db);
|
||||||
|
const draft = await quickAdd(db, u, { title: "Dune??", mediaType: "book" });
|
||||||
|
const good = await makeWork(db, { title: "Dune", externalIds: { isbn13: "1" } });
|
||||||
|
await db.k
|
||||||
|
.insertInto("match_suggestions")
|
||||||
|
.values({ id: newId(), user_id: u, work_id: draft.workId, candidate_work_id: good, score: 0.75, status: "pending", created_at: nowIso() })
|
||||||
|
.execute();
|
||||||
|
const s = await listSuggestions(db, u, draft.workId);
|
||||||
|
expect(s).toHaveLength(1);
|
||||||
|
expect(s[0]).toMatchObject({ workId: draft.workId, candidateWorkId: good, score: 0.75, status: "pending" });
|
||||||
|
const other = await makeUser(db, "bob");
|
||||||
|
await expect(listSuggestions(db, other, draft.workId)).rejects.toBeInstanceOf(NotFoundError);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("requireLinkTarget checks ownership per entity; links and filename are readable", async () => {
|
||||||
|
const db = getDb();
|
||||||
|
const dir = await mkdtemp(join(tmpdir(), "stt-att-"));
|
||||||
|
try {
|
||||||
|
const u = await makeUser(db);
|
||||||
|
const bob = await makeUser(db, "bob");
|
||||||
|
const e = await quickAdd(db, u, { title: "Dune" });
|
||||||
|
const g = await addEngagement(db, u, e.id, {}, "user");
|
||||||
|
await requireLinkTarget(db, u, "entry", e.id);
|
||||||
|
await requireLinkTarget(db, u, "engagement", g.id);
|
||||||
|
await requireLinkTarget(db, u, "work", e.workId);
|
||||||
|
await expect(requireLinkTarget(db, bob, "entry", e.id)).rejects.toBeInstanceOf(NotFoundError);
|
||||||
|
await expect(requireLinkTarget(db, bob, "engagement", g.id)).rejects.toBeInstanceOf(NotFoundError);
|
||||||
|
await expect(requireLinkTarget(db, bob, "work", e.workId)).rejects.toBeInstanceOf(NotFoundError);
|
||||||
|
await expect(requireLinkTarget(db, u, "nope" as never, e.id)).rejects.toBeInstanceOf(ValidationError);
|
||||||
|
const a = await storeAttachment(db, dir, u, { bytes: new Uint8Array([1]), mime: "image/png", filename: "a.png" }, { entity: "entry", entityId: e.id });
|
||||||
|
const links = await listAttachmentLinks(db, u, a.id);
|
||||||
|
expect(links).toEqual([{ id: expect.any(String), entity: "entry", entityId: e.id }]);
|
||||||
|
expect(await listAttachmentLinks(db, bob, a.id)).toEqual([]);
|
||||||
|
expect((await readAttachment(db, dir, u, a.id))?.filename).toBe("a.png");
|
||||||
|
} finally {
|
||||||
|
await rm(dir, { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test("getConnection hides other users and deleted connections; listRuns is newest first", async () => {
|
||||||
|
const db = getDb();
|
||||||
|
const u = await makeUser(db);
|
||||||
|
const bob = await makeUser(db, "bob");
|
||||||
|
const id = await createConnection(db, SECRET, u, { pluginId: "echo", name: "E", config: {} }, echo.manifest);
|
||||||
|
expect((await getConnection(db, u, id)).pluginId).toBe("echo");
|
||||||
|
await expect(getConnection(db, bob, id)).rejects.toBeInstanceOf(NotFoundError);
|
||||||
|
const sync = syncHandler({ db, secret: SECRET, registry: reg });
|
||||||
|
await sync({ connectionId: id, mode: "full" });
|
||||||
|
await sync({ connectionId: id, mode: "full" });
|
||||||
|
const runs = await listRuns(db, u, id);
|
||||||
|
expect(runs).toHaveLength(2);
|
||||||
|
expect(runs[0]!.startedAt >= runs[1]!.startedAt).toBe(true);
|
||||||
|
expect(runs[0]).toMatchObject({ mode: "full", status: "ok" });
|
||||||
|
await expect(listRuns(db, bob, id)).rejects.toBeInstanceOf(NotFoundError);
|
||||||
|
await deleteConnection(db, u, id);
|
||||||
|
await expect(getConnection(db, u, id)).rejects.toBeInstanceOf(NotFoundError);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("syncHandler runs file mode from a base64 payload", async () => {
|
||||||
|
const db = getDb();
|
||||||
|
const u = await makeUser(db);
|
||||||
|
const id = await createConnection(db, SECRET, u, { pluginId: "fileish", name: "F", config: {} }, fileish.manifest);
|
||||||
|
const sync = syncHandler({ db, secret: SECRET, registry: reg2 });
|
||||||
|
const s = await sync({ connectionId: id, file: { name: "x.txt", base64: Buffer.from("Dune").toString("base64") } });
|
||||||
|
expect(s).toMatchObject({ status: "ok" });
|
||||||
|
expect((await listRuns(db, u, id))[0]!.mode).toBe("file");
|
||||||
|
const entries = await db.k.selectFrom("entries").select("id").where("user_id", "=", u).execute();
|
||||||
|
expect(entries).toHaveLength(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("deviceStart and devicePoll store credentials on success", async () => {
|
||||||
|
const db = getDb();
|
||||||
|
const u = await makeUser(db);
|
||||||
|
const id = await createConnection(db, SECRET, u, { pluginId: "fileish", name: "F", config: {} }, fileish.manifest);
|
||||||
|
expect(await deviceStart(db, SECRET, reg2, u, id)).toMatchObject({ userCode: "ABCD", deviceCode: "dc" });
|
||||||
|
expect(await devicePoll(db, SECRET, reg2, u, id, "other")).toBe("pending");
|
||||||
|
expect(await devicePoll(db, SECRET, reg2, u, id, "dc")).toBe("connected");
|
||||||
|
expect(await getConnection(db, u, id)).toMatchObject({ hasCredentials: true });
|
||||||
|
const e = await createConnection(db, SECRET, u, { pluginId: "echo", name: "E", config: {} }, echo.manifest);
|
||||||
|
await expect(deviceStart(db, SECRET, reg2, u, e)).rejects.toBeInstanceOf(ValidationError);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,173 @@
|
|||||||
|
import { afterEach, beforeEach, describe, expect, test } from "bun:test";
|
||||||
|
import { mkdtemp, readdir, rm } from "node:fs/promises";
|
||||||
|
import { tmpdir } from "node:os";
|
||||||
|
import { join } from "node:path";
|
||||||
|
import { newId, nowIso } from "@sttracker/db/ids";
|
||||||
|
import { forEachDialect } from "@sttracker/db/testing";
|
||||||
|
import type { Db } from "@sttracker/db/db";
|
||||||
|
import {
|
||||||
|
listAttachments,
|
||||||
|
readAttachment,
|
||||||
|
storeAttachment,
|
||||||
|
unlinkAttachment,
|
||||||
|
} from "../src/attachments.ts";
|
||||||
|
import { ValidationError } from "../src/errors.ts";
|
||||||
|
|
||||||
|
async function addUser(db: Db, username: string): Promise<string> {
|
||||||
|
const id = newId();
|
||||||
|
const now = nowIso();
|
||||||
|
await db.k
|
||||||
|
.insertInto("users")
|
||||||
|
.values({
|
||||||
|
id,
|
||||||
|
username,
|
||||||
|
display_name: username,
|
||||||
|
password_hash: "x",
|
||||||
|
role: "member",
|
||||||
|
preferences: "{}",
|
||||||
|
created_at: now,
|
||||||
|
updated_at: now,
|
||||||
|
})
|
||||||
|
.execute();
|
||||||
|
return id;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function filesUnder(dir: string): Promise<string[]> {
|
||||||
|
const all = await readdir(dir, { recursive: true, withFileTypes: true });
|
||||||
|
return all.filter((e) => e.isFile()).map((e) => join(e.parentPath, e.name));
|
||||||
|
}
|
||||||
|
|
||||||
|
forEachDialect("attachments", (getDb) => {
|
||||||
|
let dataDir: string;
|
||||||
|
beforeEach(async () => {
|
||||||
|
dataDir = await mkdtemp(join(tmpdir(), "sttracker-att-"));
|
||||||
|
});
|
||||||
|
afterEach(async () => {
|
||||||
|
await rm(dataDir, { recursive: true, force: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
const png = (s: string) => ({
|
||||||
|
bytes: new TextEncoder().encode(s),
|
||||||
|
mime: "image/png",
|
||||||
|
filename: "a.png",
|
||||||
|
});
|
||||||
|
|
||||||
|
test("same bytes twice: one file, two links", async () => {
|
||||||
|
const db = getDb();
|
||||||
|
const u = await addUser(db, "u1");
|
||||||
|
const a = await storeAttachment(db, dataDir, u, png("hello"), { entity: "work", entityId: "w1" });
|
||||||
|
const b = await storeAttachment(db, dataDir, u, png("hello"), { entity: "entry", entityId: "e1" });
|
||||||
|
expect(a.sha256).toBe(b.sha256);
|
||||||
|
expect(a.id).not.toBe(b.id);
|
||||||
|
const files = await filesUnder(join(dataDir, "attachments"));
|
||||||
|
expect(files).toHaveLength(1);
|
||||||
|
expect(files[0]).toBe(
|
||||||
|
join(dataDir, "attachments", a.sha256.slice(0, 2), a.sha256.slice(2, 4), a.sha256),
|
||||||
|
);
|
||||||
|
const links = await db.k.selectFrom("attachment_links").selectAll().execute();
|
||||||
|
expect(links).toHaveLength(2);
|
||||||
|
expect(await listAttachments(db, u, "work", "w1")).toHaveLength(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("rejects disallowed mime and oversize", async () => {
|
||||||
|
const db = getDb();
|
||||||
|
const u = await addUser(db, "u1");
|
||||||
|
const link = { entity: "work", entityId: "w1" } as const;
|
||||||
|
await expect(
|
||||||
|
storeAttachment(db, dataDir, u, { ...png("x"), mime: "application/x-msdownload", filename: "a.exe" }, link),
|
||||||
|
).rejects.toBeInstanceOf(ValidationError);
|
||||||
|
await expect(
|
||||||
|
storeAttachment(db, dataDir, u, { ...png("x"), bytes: new Uint8Array(25 * 1024 * 1024 + 1) }, link),
|
||||||
|
).rejects.toBeInstanceOf(ValidationError);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("reads own file; another user gets null", async () => {
|
||||||
|
const db = getDb();
|
||||||
|
const u1 = await addUser(db, "u1");
|
||||||
|
const u2 = await addUser(db, "u2");
|
||||||
|
const a = await storeAttachment(db, dataDir, u1, png("secret"), { entity: "work", entityId: "w1" });
|
||||||
|
const r = await readAttachment(db, dataDir, u1, a.id);
|
||||||
|
expect(r?.mime).toBe("image/png");
|
||||||
|
expect(await r?.file.text()).toBe("secret");
|
||||||
|
expect(await readAttachment(db, dataDir, u2, a.id)).toBeNull();
|
||||||
|
expect(await readAttachment(db, dataDir, u1, "nope")).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("unlink removes link, orphan row and unshared file", async () => {
|
||||||
|
const db = getDb();
|
||||||
|
const u = await addUser(db, "u1");
|
||||||
|
const a = await storeAttachment(db, dataDir, u, png("bye"), { entity: "work", entityId: "w1" });
|
||||||
|
const b = await storeAttachment(db, dataDir, u, png("bye"), { entity: "work", entityId: "w2" });
|
||||||
|
await unlinkAttachment(db, dataDir, u, a.id, "work", "w1");
|
||||||
|
expect(await listAttachments(db, u, "work", "w1")).toHaveLength(0);
|
||||||
|
expect(await filesUnder(join(dataDir, "attachments"))).toHaveLength(1);
|
||||||
|
await unlinkAttachment(db, dataDir, u, b.id, "work", "w2");
|
||||||
|
expect(await filesUnder(join(dataDir, "attachments"))).toHaveLength(0);
|
||||||
|
expect(await db.k.selectFrom("attachments").selectAll().execute()).toHaveLength(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("rejects svg and normalizes mime", async () => {
|
||||||
|
const db = getDb();
|
||||||
|
const u = await addUser(db, "u1");
|
||||||
|
const link = { entity: "work", entityId: "w1" } as const;
|
||||||
|
for (const mime of ["image/svg+xml", "IMAGE/SVG+XML; charset=utf-8"]) {
|
||||||
|
await expect(storeAttachment(db, dataDir, u, { ...png("x"), mime }, link)).rejects.toBeInstanceOf(
|
||||||
|
ValidationError,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const a = await storeAttachment(db, dataDir, u, { ...png("x"), mime: "Image/PNG; foo=bar" }, link);
|
||||||
|
expect(a.mime).toBe("image/png");
|
||||||
|
expect((await listAttachments(db, u, "work", "w1"))[0]?.mime).toBe("image/png");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("shared bytes across users survive the other's unlink", async () => {
|
||||||
|
const db = getDb();
|
||||||
|
const ua = await addUser(db, "a");
|
||||||
|
const ub = await addUser(db, "b");
|
||||||
|
const a = await storeAttachment(db, dataDir, ua, png("same"), { entity: "work", entityId: "w1" });
|
||||||
|
const b = await storeAttachment(db, dataDir, ub, png("same"), { entity: "work", entityId: "w1" });
|
||||||
|
await unlinkAttachment(db, dataDir, ua, a.id, "work", "w1");
|
||||||
|
expect(await filesUnder(join(dataDir, "attachments"))).toHaveLength(1);
|
||||||
|
const r = await readAttachment(db, dataDir, ub, b.id);
|
||||||
|
expect(await r?.file.text()).toBe("same");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("another user's unlink is a no-op", async () => {
|
||||||
|
const db = getDb();
|
||||||
|
const ua = await addUser(db, "a");
|
||||||
|
const ub = await addUser(db, "b");
|
||||||
|
const a = await storeAttachment(db, dataDir, ua, png("mine"), { entity: "work", entityId: "w1" });
|
||||||
|
await unlinkAttachment(db, dataDir, ub, a.id, "work", "w1");
|
||||||
|
expect(await listAttachments(db, ua, "work", "w1")).toHaveLength(1);
|
||||||
|
expect(await filesUnder(join(dataDir, "attachments"))).toHaveLength(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("listAttachments is scoped to the user", async () => {
|
||||||
|
const db = getDb();
|
||||||
|
const ua = await addUser(db, "a");
|
||||||
|
const ub = await addUser(db, "b");
|
||||||
|
await storeAttachment(db, dataDir, ua, png("one"), { entity: "work", entityId: "w1" });
|
||||||
|
expect(await listAttachments(db, ub, "work", "w1")).toHaveLength(0);
|
||||||
|
expect(await listAttachments(db, ua, "work", "w1")).toHaveLength(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("concurrent store and unlink never leave a row without its file", async () => {
|
||||||
|
const db = getDb();
|
||||||
|
const u = await addUser(db, "u1");
|
||||||
|
const first = await storeAttachment(db, dataDir, u, png("race"), { entity: "work", entityId: "w1" });
|
||||||
|
const [, second] = await Promise.all([
|
||||||
|
unlinkAttachment(db, dataDir, u, first.id, "work", "w1"),
|
||||||
|
storeAttachment(db, dataDir, u, png("race"), { entity: "work", entityId: "w2" }),
|
||||||
|
]);
|
||||||
|
const r = await readAttachment(db, dataDir, u, second.id);
|
||||||
|
expect(await r?.file.text()).toBe("race");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("leaves no temp files behind", async () => {
|
||||||
|
const db = getDb();
|
||||||
|
const u = await addUser(db, "u1");
|
||||||
|
await storeAttachment(db, dataDir, u, png("clean"), { entity: "work", entityId: "w1" });
|
||||||
|
const files = await filesUnder(join(dataDir, "attachments"));
|
||||||
|
expect(files.some((f) => f.includes(".tmp-"))).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,155 @@
|
|||||||
|
import { expect, test } from "bun:test";
|
||||||
|
import { forEachDialect } from "@sttracker/db/testing";
|
||||||
|
import {
|
||||||
|
createAppToken,
|
||||||
|
createInvite,
|
||||||
|
getSessionUser,
|
||||||
|
login,
|
||||||
|
logout,
|
||||||
|
registerUser,
|
||||||
|
revokeAppToken,
|
||||||
|
verifyAppToken,
|
||||||
|
} from "../src/auth.ts";
|
||||||
|
import { AuthError } from "../src/errors.ts";
|
||||||
|
|
||||||
|
async function code(p: Promise<unknown>): Promise<string | undefined> {
|
||||||
|
try {
|
||||||
|
await p;
|
||||||
|
} catch (e) {
|
||||||
|
return e instanceof AuthError ? e.code : "other";
|
||||||
|
}
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
forEachDialect("auth", (getDb) => {
|
||||||
|
test("first user is admin and username is lowercased", async () => {
|
||||||
|
const u = await registerUser(getDb(), { username: "Alice", password: "pw-one" });
|
||||||
|
expect(u.role).toBe("admin");
|
||||||
|
expect(u.username).toBe("alice");
|
||||||
|
expect(u.displayName).toBe("alice");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("invalid and duplicate usernames are rejected", async () => {
|
||||||
|
const db = getDb();
|
||||||
|
expect(await code(registerUser(db, { username: "a b", password: "x" }))).toBe("invalid_username");
|
||||||
|
await registerUser(db, { username: "alice", password: "x" });
|
||||||
|
const inv = await createInvite(db, (await login(db, "alice", "x")).user.id);
|
||||||
|
expect(await code(registerUser(db, { username: "ALICE", password: "x", inviteCode: inv }))).toBe(
|
||||||
|
"username_taken",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("second user needs a single-use invite", async () => {
|
||||||
|
const db = getDb();
|
||||||
|
const admin = await registerUser(db, { username: "alice", password: "x" });
|
||||||
|
expect(await code(registerUser(db, { username: "bob", password: "x" }))).toBe("invite_required");
|
||||||
|
expect(await code(registerUser(db, { username: "bob", password: "x", inviteCode: "nope" }))).toBe(
|
||||||
|
"invite_required",
|
||||||
|
);
|
||||||
|
const inv = await createInvite(db, admin.id);
|
||||||
|
const bob = await registerUser(db, { username: "bob", password: "x", inviteCode: inv });
|
||||||
|
expect(bob.role).toBe("member");
|
||||||
|
expect(await code(registerUser(db, { username: "carol", password: "x", inviteCode: inv }))).toBe(
|
||||||
|
"invite_required",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("expired invite is rejected; members cannot create invites", async () => {
|
||||||
|
const db = getDb();
|
||||||
|
const admin = await registerUser(db, { username: "alice", password: "x" });
|
||||||
|
const inv = await createInvite(db, admin.id, -1);
|
||||||
|
expect(await code(registerUser(db, { username: "bob", password: "x", inviteCode: inv }))).toBe(
|
||||||
|
"invite_required",
|
||||||
|
);
|
||||||
|
const ok = await createInvite(db, admin.id);
|
||||||
|
const bob = await registerUser(db, { username: "bob", password: "x", inviteCode: ok });
|
||||||
|
expect(await code(createInvite(db, bob.id))).toBe("forbidden");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("wrong password or unknown user -> invalid_credentials", async () => {
|
||||||
|
const db = getDb();
|
||||||
|
await registerUser(db, { username: "alice", password: "right" });
|
||||||
|
expect(await code(login(db, "alice", "wrong"))).toBe("invalid_credentials");
|
||||||
|
expect(await code(login(db, "nobody", "x"))).toBe("invalid_credentials");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("session round-trip and logout", async () => {
|
||||||
|
const db = getDb();
|
||||||
|
await registerUser(db, { username: "alice", password: "pw" });
|
||||||
|
const { user, sessionToken } = await login(db, "Alice", "pw");
|
||||||
|
expect(await getSessionUser(db, sessionToken)).toEqual(user);
|
||||||
|
const stored = await db.k.selectFrom("sessions").selectAll().execute();
|
||||||
|
expect(stored[0]!.token_hash).not.toBe(sessionToken);
|
||||||
|
await logout(db, sessionToken);
|
||||||
|
expect(await getSessionUser(db, sessionToken)).toBeNull();
|
||||||
|
expect(await getSessionUser(db, "garbage")).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("expired session returns null", async () => {
|
||||||
|
const db = getDb();
|
||||||
|
await registerUser(db, { username: "alice", password: "pw" });
|
||||||
|
const { sessionToken } = await login(db, "alice", "pw");
|
||||||
|
await db.k.updateTable("sessions").set({ expires_at: new Date(Date.now() - 1000).toISOString() }).execute();
|
||||||
|
expect(await getSessionUser(db, sessionToken)).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("app tokens: scope check, last_used_at, revoke", async () => {
|
||||||
|
const db = getDb();
|
||||||
|
const u = await registerUser(db, { username: "alice", password: "pw" });
|
||||||
|
const { id, token } = await createAppToken(db, u.id, "cli", ["read"]);
|
||||||
|
expect(token.startsWith("stt_")).toBe(true);
|
||||||
|
expect(await verifyAppToken(db, token, "write")).toBeNull();
|
||||||
|
expect(await verifyAppToken(db, token, "read")).toEqual(u);
|
||||||
|
const row = await db.k.selectFrom("app_tokens").selectAll().executeTakeFirstOrThrow();
|
||||||
|
expect(row.last_used_at).not.toBeNull();
|
||||||
|
expect(await verifyAppToken(db, "stt_bogus", "read")).toBeNull();
|
||||||
|
await revokeAppToken(db, u.id, id);
|
||||||
|
expect(await verifyAppToken(db, token, "read")).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("concurrent first registrations yield exactly one admin", async () => {
|
||||||
|
const db = getDb();
|
||||||
|
const results = await Promise.allSettled([
|
||||||
|
registerUser(db, { username: "alice", password: "x" }),
|
||||||
|
registerUser(db, { username: "bob", password: "x" }),
|
||||||
|
]);
|
||||||
|
const ok = results.filter((r) => r.status === "fulfilled");
|
||||||
|
const bad = results.filter((r) => r.status === "rejected");
|
||||||
|
expect(ok.length).toBe(1);
|
||||||
|
expect((ok[0] as PromiseFulfilledResult<{ role: string }>).value.role).toBe("admin");
|
||||||
|
expect(((bad[0] as PromiseRejectedResult).reason as AuthError).code).toBe("invite_required");
|
||||||
|
const admins = await db.k.selectFrom("users").select("id").where("role", "=", "admin").execute();
|
||||||
|
expect(admins.length).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("no invite means invite_required even for a taken username", async () => {
|
||||||
|
const db = getDb();
|
||||||
|
await registerUser(db, { username: "alice", password: "x" });
|
||||||
|
expect(await code(registerUser(db, { username: "alice", password: "x" }))).toBe("invite_required");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("only hashes are stored for invites and app tokens", async () => {
|
||||||
|
const db = getDb();
|
||||||
|
const u = await registerUser(db, { username: "alice", password: "x" });
|
||||||
|
const inv = await createInvite(db, u.id);
|
||||||
|
const { token } = await createAppToken(db, u.id, "cli", ["read"]);
|
||||||
|
const invRow = await db.k.selectFrom("invites").selectAll().executeTakeFirstOrThrow();
|
||||||
|
const tokRow = await db.k.selectFrom("app_tokens").selectAll().executeTakeFirstOrThrow();
|
||||||
|
expect(invRow.code_hash).not.toBe(inv);
|
||||||
|
expect(tokRow.token_hash).not.toBe(token);
|
||||||
|
expect(tokRow.token_hash).not.toContain(token);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("revoking with another user's id leaves the token valid", async () => {
|
||||||
|
const db = getDb();
|
||||||
|
const a = await registerUser(db, { username: "alice", password: "x" });
|
||||||
|
const b = await registerUser(db, {
|
||||||
|
username: "bob",
|
||||||
|
password: "x",
|
||||||
|
inviteCode: await createInvite(db, a.id),
|
||||||
|
});
|
||||||
|
const { id, token } = await createAppToken(db, a.id, "cli", ["read"]);
|
||||||
|
await revokeAppToken(db, b.id, id);
|
||||||
|
expect(await verifyAppToken(db, token, "read")).toEqual(a);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,53 @@
|
|||||||
|
import { afterEach, beforeEach, expect, test } from "bun:test";
|
||||||
|
import { mkdtemp, readdir, rm, stat } from "node:fs/promises";
|
||||||
|
import { tmpdir } from "node:os";
|
||||||
|
import { join } from "node:path";
|
||||||
|
import { forEachDialect } from "@sttracker/db/testing";
|
||||||
|
import { backupHandler, runBackup } from "../src/backup.ts";
|
||||||
|
import { makeUser } from "./user-layer-fixtures.ts";
|
||||||
|
|
||||||
|
forEachDialect("backup", (getDb) => {
|
||||||
|
let dir: string;
|
||||||
|
beforeEach(async () => {
|
||||||
|
dir = await mkdtemp(join(tmpdir(), "sttracker-bak-"));
|
||||||
|
});
|
||||||
|
afterEach(async () => {
|
||||||
|
await rm(dir, { recursive: true, force: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
test("backup writes a bundle per user (and the db on sqlite)", async () => {
|
||||||
|
const db = getDb();
|
||||||
|
await makeUser(db, "ada");
|
||||||
|
await makeUser(db, "bob");
|
||||||
|
const out = await runBackup(db, dir, { keep: 14 });
|
||||||
|
expect(out.startsWith(join(dir, "backups"))).toBe(true);
|
||||||
|
const files = (await readdir(out)).sort();
|
||||||
|
expect(files).toEqual(db.dialect === "sqlite" ? ["ada.zip", "bob.zip", "sttracker.db"] : ["ada.zip", "bob.zip"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("backup prunes to keep", async () => {
|
||||||
|
const db = getDb();
|
||||||
|
await makeUser(db, "ada");
|
||||||
|
const made: string[] = [];
|
||||||
|
for (let i = 0; i < 4; i++) made.push(await runBackup(db, dir, { keep: 2 }));
|
||||||
|
const left = (await readdir(join(dir, "backups"))).sort();
|
||||||
|
expect(left).toHaveLength(2);
|
||||||
|
expect(left.map((n) => join(dir, "backups", n))).toEqual(made.slice(2));
|
||||||
|
});
|
||||||
|
|
||||||
|
test("backupHandler runs a backup", async () => {
|
||||||
|
const db = getDb();
|
||||||
|
await makeUser(db, "ada");
|
||||||
|
await backupHandler({ db, dataDir: dir, keep: 3 })();
|
||||||
|
expect(await readdir(join(dir, "backups"))).toHaveLength(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("backup files are private and no partial directory remains", async () => {
|
||||||
|
const db = getDb();
|
||||||
|
await makeUser(db, "ada");
|
||||||
|
const out = await runBackup(db, dir, { keep: 5 });
|
||||||
|
expect((await stat(out)).mode & 0o777).toBe(0o700);
|
||||||
|
expect((await stat(join(out, "ada.zip"))).mode & 0o777).toBe(0o600);
|
||||||
|
expect((await readdir(join(dir, "backups"))).some((n) => n.endsWith(".tmp"))).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,164 @@
|
|||||||
|
import { expect, spyOn, test } from "bun:test";
|
||||||
|
import { join } from "node:path";
|
||||||
|
import { forEachDialect } from "@sttracker/db/testing";
|
||||||
|
import { decryptJson } from "../src/crypto.ts";
|
||||||
|
import { DropJobError, NonRetryableError } from "../src/jobs.ts";
|
||||||
|
import { createConnection, deleteConnection, listConnections, makePluginContext, syncHandler, updateConnection } from "../src/connections.ts";
|
||||||
|
import { loadPlugins } from "../src/registry.ts";
|
||||||
|
import { makeUser } from "./sync/helpers.ts";
|
||||||
|
import { newId, nowIso } from "@sttracker/db/ids";
|
||||||
|
import type { Connection } from "../src/sync/run.ts";
|
||||||
|
|
||||||
|
const SECRET = "test-secret";
|
||||||
|
const warn = spyOn(console, "warn").mockImplementation(() => {});
|
||||||
|
const reg = await loadPlugins([join(import.meta.dir, "fixtures/plugins")]);
|
||||||
|
warn.mockRestore();
|
||||||
|
const echo = reg.get("echo")!;
|
||||||
|
|
||||||
|
forEachDialect("connections", (getDb) => {
|
||||||
|
const raw = (id: string) => getDb().k.selectFrom("source_connections").selectAll().where("id", "=", id).executeTakeFirstOrThrow();
|
||||||
|
const asConn = async (id: string): Promise<Connection> => {
|
||||||
|
const r = await raw(id);
|
||||||
|
return { id, userId: r.user_id, pluginId: r.plugin_id, name: r.name, config: JSON.parse(r.config), credentialsEnc: r.credentials_enc, scheduleMinutes: null, cursor: r.cursor, status: r.status, lastRunAt: null, createdAt: r.created_at };
|
||||||
|
};
|
||||||
|
|
||||||
|
test("secrets live only in the encrypted blob and never in list", async () => {
|
||||||
|
const db = getDb();
|
||||||
|
const u = await makeUser(db);
|
||||||
|
const id = await createConnection(db, SECRET, u, { pluginId: "echo", name: "E", config: { label: "hi", token: "s3cret" } }, echo.manifest);
|
||||||
|
const r = await raw(id);
|
||||||
|
expect(JSON.parse(r.config)).toEqual({ label: "hi" });
|
||||||
|
expect(r.credentials_enc).not.toContain("s3cret");
|
||||||
|
expect(await decryptJson(SECRET, r.credentials_enc!)).toEqual({ token: "s3cret" });
|
||||||
|
const [v] = await listConnections(db, u);
|
||||||
|
expect(JSON.stringify(v)).not.toContain("s3cret");
|
||||||
|
expect(v).toMatchObject({ id, config: { label: "hi" }, hasCredentials: true });
|
||||||
|
const ctx = await makePluginContext(db, SECRET, await asConn(id), "r", echo.manifest);
|
||||||
|
expect(ctx.config).toEqual({ label: "hi", token: "s3cret" });
|
||||||
|
expect(await ctx.credentials.get()).toEqual({ token: "s3cret" });
|
||||||
|
});
|
||||||
|
|
||||||
|
test("update keeps omitted secrets; delete pauses and clears credentials", async () => {
|
||||||
|
const db = getDb();
|
||||||
|
const u = await makeUser(db);
|
||||||
|
const id = await createConnection(db, SECRET, u, { pluginId: "echo", name: "E", config: { token: "t1" } }, echo.manifest);
|
||||||
|
await updateConnection(db, SECRET, u, id, { config: { label: "new" }, scheduleMinutes: 30 }, echo.manifest);
|
||||||
|
let r = await raw(id);
|
||||||
|
expect(JSON.parse(r.config)).toEqual({ label: "new" });
|
||||||
|
expect(r.schedule_minutes).toBe(30);
|
||||||
|
expect(await decryptJson(SECRET, r.credentials_enc!)).toEqual({ token: "t1" });
|
||||||
|
await updateConnection(db, SECRET, u, id, { config: { token: "t2" } }, echo.manifest);
|
||||||
|
expect(await decryptJson(SECRET, (await raw(id)).credentials_enc!)).toEqual({ token: "t2" });
|
||||||
|
const other = await makeUser(db, "bob");
|
||||||
|
await expect(deleteConnection(db, other, id)).rejects.toThrow();
|
||||||
|
await deleteConnection(db, u, id);
|
||||||
|
r = await raw(id);
|
||||||
|
expect(r.status).toBe("paused");
|
||||||
|
expect(r.credentials_enc).toBeNull();
|
||||||
|
expect((await listConnections(db, u))[0]!.hasCredentials).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("cursor persists on set; http uses manifest rate limit", async () => {
|
||||||
|
const db = getDb();
|
||||||
|
const u = await makeUser(db);
|
||||||
|
const id = await createConnection(db, SECRET, u, { pluginId: "echo", name: "E", config: {} }, echo.manifest);
|
||||||
|
const ctx = await makePluginContext(db, SECRET, await asConn(id), "r", echo.manifest);
|
||||||
|
await ctx.cursor.set("c1");
|
||||||
|
expect((await raw(id)).cursor).toBe("c1");
|
||||||
|
expect(await ctx.cursor.get()).toBe("c1");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("credentials.save persists encrypted before pull continues", async () => {
|
||||||
|
const db = getDb();
|
||||||
|
const u = await makeUser(db);
|
||||||
|
const id = await createConnection(db, SECRET, u, { pluginId: "echo", name: "E", config: { label: "explode", token: "t" } }, echo.manifest);
|
||||||
|
const handler = syncHandler({ db, secret: SECRET, registry: reg });
|
||||||
|
await expect(handler({ connectionId: id })).rejects.toThrow();
|
||||||
|
const r = await raw(id);
|
||||||
|
expect(await decryptJson(SECRET, r.credentials_enc!)).toEqual({ token: "t", refresh: "new-refresh" });
|
||||||
|
expect(r.status).toBe("error");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("sync handler: full first, changes after a recent full, full when payload says so or full is stale", async () => {
|
||||||
|
const db = getDb();
|
||||||
|
const u = await makeUser(db);
|
||||||
|
const id = await createConnection(db, SECRET, u, { pluginId: "echo", name: "E", config: { label: "ok" } }, echo.manifest);
|
||||||
|
const handler = syncHandler({ db, secret: SECRET, registry: reg });
|
||||||
|
const modes = async () => (await db.k.selectFrom("source_runs").select(["mode", "status"]).where("connection_id", "=", id).orderBy("started_at").orderBy("id").execute()).map((r) => `${r.mode}:${r.status}`);
|
||||||
|
await handler({ connectionId: id });
|
||||||
|
await handler({ connectionId: id });
|
||||||
|
await handler({ connectionId: id, mode: "full" });
|
||||||
|
expect(await modes()).toEqual(["full:ok", "changes:ok", "full:ok"]);
|
||||||
|
await db.k.updateTable("source_runs").set({ started_at: new Date(Date.now() - 8 * 86400_000).toISOString() }).where("connection_id", "=", id).execute();
|
||||||
|
await handler({ connectionId: id });
|
||||||
|
expect((await modes()).at(-1)).toBe("full:ok");
|
||||||
|
await db.k.insertInto("source_runs").values({ id: newId(), connection_id: id, mode: "full", started_at: nowIso(), finished_at: null, status: "failed", counts: "{}", log: "", error: "x" }).execute();
|
||||||
|
await handler({ connectionId: id });
|
||||||
|
expect((await modes()).at(-1)).toBe("changes:ok");
|
||||||
|
});
|
||||||
|
|
||||||
|
const setup = async (config: Record<string, unknown>) => {
|
||||||
|
const db = getDb();
|
||||||
|
const u = await makeUser(db);
|
||||||
|
const id = await createConnection(db, SECRET, u, { pluginId: "echo", name: "E", config }, echo.manifest);
|
||||||
|
return { db, id };
|
||||||
|
};
|
||||||
|
const failedRuns = (id: string) => getDb().k.selectFrom("source_runs").selectAll().where("connection_id", "=", id).execute();
|
||||||
|
|
||||||
|
test("missing plugin: failed run, connection error, no retry", async () => {
|
||||||
|
const { db, id } = await setup({});
|
||||||
|
await db.k.updateTable("source_connections").set({ plugin_id: "gone" }).where("id", "=", id).execute();
|
||||||
|
await expect(syncHandler({ db, secret: SECRET, registry: reg })({ connectionId: id })).rejects.toBeInstanceOf(NonRetryableError);
|
||||||
|
const runs = await failedRuns(id);
|
||||||
|
expect(runs).toHaveLength(1);
|
||||||
|
expect(runs[0]).toMatchObject({ status: "failed", mode: "full", error: "Plugin 'gone' is not installed or failed to load." });
|
||||||
|
const c = await raw(id);
|
||||||
|
expect(c.status).toBe("error");
|
||||||
|
expect(c.last_run_at).not.toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("undecryptable credentials: failed run, connection error, no retry", async () => {
|
||||||
|
const { db, id } = await setup({ token: "t" });
|
||||||
|
await expect(syncHandler({ db, secret: "other-secret", registry: reg })({ connectionId: id })).rejects.toBeInstanceOf(NonRetryableError);
|
||||||
|
const runs = await failedRuns(id);
|
||||||
|
expect(runs[0]!.error).toBe("Cannot decrypt this source's credentials. Was STTRACKER_SECRET changed? Reconnect the source to fix it.");
|
||||||
|
expect((await raw(id)).status).toBe("error");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("missing or paused connection drops the job", async () => {
|
||||||
|
const { db, id } = await setup({});
|
||||||
|
const h = syncHandler({ db, secret: SECRET, registry: reg });
|
||||||
|
await expect(h({ connectionId: newId() })).rejects.toBeInstanceOf(DropJobError);
|
||||||
|
await db.k.updateTable("source_connections").set({ status: "paused" }).where("id", "=", id).execute();
|
||||||
|
await expect(h({ connectionId: id })).rejects.toBeInstanceOf(DropJobError);
|
||||||
|
expect(await failedRuns(id)).toHaveLength(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("ctx.log lands in the run's log", async () => {
|
||||||
|
const { db, id } = await setup({ label: "ok" });
|
||||||
|
const res = await syncHandler({ db, secret: SECRET, registry: reg })({ connectionId: id });
|
||||||
|
const run = await db.k.selectFrom("source_runs").selectAll().where("id", "=", res!.runId).executeTakeFirstOrThrow();
|
||||||
|
expect(run.log).toContain("echo pulled");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("concurrent credentials.save calls keep every key", async () => {
|
||||||
|
const { db, id } = await setup({ token: "t" });
|
||||||
|
const conn = await asConn(id);
|
||||||
|
const ctx = await makePluginContext(db, SECRET, conn, "r", echo.manifest);
|
||||||
|
await Promise.all([ctx.credentials.save({ a: "1" }), ctx.credentials.save({ b: "2" })]);
|
||||||
|
expect(await decryptJson(SECRET, (await raw(id)).credentials_enc!)).toEqual({ token: "t", a: "1", b: "2" });
|
||||||
|
});
|
||||||
|
|
||||||
|
test("ctx.http is limited by manifest.rateLimit", async () => {
|
||||||
|
const { db, id } = await setup({});
|
||||||
|
let t = 0;
|
||||||
|
const sleeps: number[] = [];
|
||||||
|
const manifest = { ...echo.manifest, rateLimit: { perMinute: 60, burst: 1 } };
|
||||||
|
const ctx = await makePluginContext(db, SECRET, await asConn(id), "r", manifest, {
|
||||||
|
httpOptions: { fetchImpl: (async () => Response.json({})) as unknown as typeof fetch, now: () => t, sleep: async (ms) => { sleeps.push(ms); t += ms; } },
|
||||||
|
});
|
||||||
|
await ctx.http.json("https://example.test/a");
|
||||||
|
await ctx.http.json("https://example.test/b");
|
||||||
|
expect(sleeps).toEqual([1000]);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,76 @@
|
|||||||
|
import { expect, test } from "bun:test";
|
||||||
|
import { forEachDialect } from "@sttracker/db/testing";
|
||||||
|
import { createCopy, listCopies, softDeleteCopy, updateCopy } from "../src/copies.ts";
|
||||||
|
import { defineCustomField } from "../src/custom-fields.ts";
|
||||||
|
import { NotFoundError, ValidationError } from "../src/errors.ts";
|
||||||
|
import { getHistory, restoreVersion } from "../src/history.ts";
|
||||||
|
import { makeUser, makeWork } from "./user-layer-fixtures.ts";
|
||||||
|
|
||||||
|
forEachDialect("copies", (getDb) => {
|
||||||
|
test("createCopy stores price in cents and partial acquired date", async () => {
|
||||||
|
const db = getDb();
|
||||||
|
const userId = await makeUser(db);
|
||||||
|
const workId = await makeWork(db);
|
||||||
|
const c = await createCopy(db, userId, {
|
||||||
|
workId,
|
||||||
|
format: "hardback",
|
||||||
|
acquiredFrom: "Waterstones",
|
||||||
|
acquiredAt: { value: "2019-03", precision: "month" },
|
||||||
|
priceCents: 1299,
|
||||||
|
currency: "GBP",
|
||||||
|
});
|
||||||
|
expect(c.priceCents).toBe(1299);
|
||||||
|
expect(c.acquiredAt).toEqual({ value: "2019-03", precision: "month" });
|
||||||
|
const row = await db.k.selectFrom("copies").selectAll().where("id", "=", c.id).executeTakeFirstOrThrow();
|
||||||
|
expect(row.price_cents).toBe(1299);
|
||||||
|
expect(row.acquired_precision).toBe("month");
|
||||||
|
|
||||||
|
await expect(createCopy(db, userId, { workId, priceCents: 12.5 })).rejects.toThrow(ValidationError);
|
||||||
|
await expect(createCopy(db, userId, { workId, currency: "pounds" })).rejects.toThrow(ValidationError);
|
||||||
|
await expect(createCopy(db, userId, {})).rejects.toThrow(ValidationError);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("unmatched copy keeps its title hint; listCopies filters by work", async () => {
|
||||||
|
const db = getDb();
|
||||||
|
const userId = await makeUser(db);
|
||||||
|
const workId = await makeWork(db);
|
||||||
|
const steam = await createCopy(db, userId, { titleHint: "HALF-LIFE 2", format: "Steam", sourceRef: "steam:220" }, "plugin:steam:c1");
|
||||||
|
const book = await createCopy(db, userId, { workId });
|
||||||
|
expect(steam.workId).toBeNull();
|
||||||
|
expect(steam.titleHint).toBe("HALF-LIFE 2");
|
||||||
|
expect((await listCopies(db, userId, {})).map((c) => c.id).sort()).toEqual([steam.id, book.id].sort());
|
||||||
|
expect((await listCopies(db, userId, { workId })).map((c) => c.id)).toEqual([book.id]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("updateCopy writes history; soft delete and restore", async () => {
|
||||||
|
const db = getDb();
|
||||||
|
const userId = await makeUser(db);
|
||||||
|
const def = await defineCustomField(db, userId, { name: "Signed", type: "boolean", level: "copy" });
|
||||||
|
const c = await createCopy(db, userId, { workId: await makeWork(db), notes: "shelf 3" });
|
||||||
|
const up = await updateCopy(db, userId, c.id, { notes: "shelf 4", customFields: { [def.id]: true } });
|
||||||
|
expect(up.notes).toBe("shelf 4");
|
||||||
|
expect(up.customFields).toEqual({ [def.id]: true });
|
||||||
|
await expect(updateCopy(db, userId, c.id, { customFields: { [def.id]: "yes" } })).rejects.toThrow(ValidationError);
|
||||||
|
const [h] = await getHistory(db, userId, "copy", c.id);
|
||||||
|
expect(h!.before).toEqual({ notes: "shelf 3", customFields: {} });
|
||||||
|
|
||||||
|
await softDeleteCopy(db, userId, c.id);
|
||||||
|
expect(await listCopies(db, userId, {})).toEqual([]);
|
||||||
|
const [del] = await getHistory(db, userId, "copy", c.id);
|
||||||
|
await restoreVersion(db, userId, del!.id);
|
||||||
|
expect((await listCopies(db, userId, {})).map((x) => x.id)).toEqual([c.id]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("another user's copies are not found", async () => {
|
||||||
|
const db = getDb();
|
||||||
|
const ada = await makeUser(db);
|
||||||
|
const bob = await makeUser(db, "bob");
|
||||||
|
const c = await createCopy(db, ada, { titleHint: "x" });
|
||||||
|
await expect(updateCopy(db, bob, c.id, { notes: "y" })).rejects.toThrow(NotFoundError);
|
||||||
|
await expect(softDeleteCopy(db, bob, c.id)).rejects.toThrow(NotFoundError);
|
||||||
|
expect(await listCopies(db, bob, {})).toEqual([]);
|
||||||
|
await expect(createCopy(db, bob, { workId: await makeWork(db, { ownerUserId: ada }) })).rejects.toThrow(
|
||||||
|
NotFoundError,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
import { describe, expect, test } from "bun:test";
|
||||||
|
import { decryptJson, encryptJson } from "../src/crypto.ts";
|
||||||
|
import { CryptoError } from "../src/errors.ts";
|
||||||
|
|
||||||
|
describe("crypto", () => {
|
||||||
|
test("round-trips JSON", async () => {
|
||||||
|
const value = { token: "abc", n: [1, 2, { x: null }] };
|
||||||
|
const blob = await encryptJson("s3cret", value);
|
||||||
|
expect(blob).toMatch(/^v1\.[A-Za-z0-9+/=]+\.[A-Za-z0-9+/=]+$/);
|
||||||
|
expect(await decryptJson("s3cret", blob)).toEqual(value);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("uses a fresh IV each time", async () => {
|
||||||
|
expect(await encryptJson("k", 1)).not.toBe(await encryptJson("k", 1));
|
||||||
|
});
|
||||||
|
|
||||||
|
test("wrong secret throws CryptoError", async () => {
|
||||||
|
const blob = await encryptJson("right", { a: 1 });
|
||||||
|
await expect(decryptJson("wrong", blob)).rejects.toBeInstanceOf(CryptoError);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("malformed blob throws CryptoError", async () => {
|
||||||
|
await expect(decryptJson("k", "garbage")).rejects.toBeInstanceOf(CryptoError);
|
||||||
|
await expect(decryptJson("k", "v2.AA==.AA==")).rejects.toBeInstanceOf(CryptoError);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("IV decodes to 12 bytes", async () => {
|
||||||
|
const blob = await encryptJson("k", { a: 1 });
|
||||||
|
expect(Buffer.from(blob.split(".")[1]!, "base64")).toHaveLength(12);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("flipping a ciphertext byte throws CryptoError", async () => {
|
||||||
|
const [v, iv, ct] = (await encryptJson("k", { a: 1 })).split(".");
|
||||||
|
const bytes = Buffer.from(ct!, "base64");
|
||||||
|
bytes[0] = bytes[0]! ^ 0xff;
|
||||||
|
const tampered = `${v}.${iv}.${bytes.toString("base64")}`;
|
||||||
|
await expect(decryptJson("k", tampered)).rejects.toBeInstanceOf(CryptoError);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,103 @@
|
|||||||
|
import { expect, test } from "bun:test";
|
||||||
|
import { forEachDialect } from "@sttracker/db/testing";
|
||||||
|
import { ValidationError } from "../src/errors.ts";
|
||||||
|
import { defineCustomField, listCustomFields, validateCustomValues } from "../src/custom-fields.ts";
|
||||||
|
import { makeUser } from "./user-layer-fixtures.ts";
|
||||||
|
|
||||||
|
forEachDialect("custom fields", (getDb) => {
|
||||||
|
test("custom select field rejects unknown option", async () => {
|
||||||
|
const db = getDb();
|
||||||
|
const userId = await makeUser(db);
|
||||||
|
const mood = await defineCustomField(db, userId, {
|
||||||
|
name: "Mood",
|
||||||
|
type: "single_select",
|
||||||
|
options: ["cosy", "grim"],
|
||||||
|
});
|
||||||
|
expect(() => validateCustomValues([mood], { [mood.id]: "cosy" })).not.toThrow();
|
||||||
|
let err: unknown;
|
||||||
|
try {
|
||||||
|
validateCustomValues([mood], { [mood.id]: "chaotic" });
|
||||||
|
} catch (e) {
|
||||||
|
err = e;
|
||||||
|
}
|
||||||
|
expect(err).toBeInstanceOf(ValidationError);
|
||||||
|
expect((err as ValidationError).field).toBe("customFields.Mood");
|
||||||
|
expect((err as ValidationError).reason).toBe("unknown_option");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("listCustomFields filters by media type and stays per user", async () => {
|
||||||
|
const db = getDb();
|
||||||
|
const userId = await makeUser(db);
|
||||||
|
const other = await makeUser(db, "bob");
|
||||||
|
await defineCustomField(db, userId, { name: "Translator", type: "text", appliesTo: ["book", "manga"] });
|
||||||
|
await defineCustomField(db, userId, { name: "Recommended by", type: "text" });
|
||||||
|
await defineCustomField(db, other, { name: "Bob field", type: "text" });
|
||||||
|
|
||||||
|
expect((await listCustomFields(db, userId)).map((d) => d.name).sort()).toEqual(["Recommended by", "Translator"]);
|
||||||
|
expect((await listCustomFields(db, userId, "game")).map((d) => d.name)).toEqual(["Recommended by"]);
|
||||||
|
expect((await listCustomFields(db, userId, "manga")).map((d) => d.name).sort()).toEqual([
|
||||||
|
"Recommended by",
|
||||||
|
"Translator",
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("defineCustomField rejects bad input", async () => {
|
||||||
|
const db = getDb();
|
||||||
|
const userId = await makeUser(db);
|
||||||
|
await expect(defineCustomField(db, userId, { name: " ", type: "text" })).rejects.toThrow(ValidationError);
|
||||||
|
await expect(defineCustomField(db, userId, { name: "X", type: "colour" as never })).rejects.toThrow(
|
||||||
|
ValidationError,
|
||||||
|
);
|
||||||
|
await expect(defineCustomField(db, userId, { name: "S", type: "single_select" })).rejects.toThrow(
|
||||||
|
ValidationError,
|
||||||
|
);
|
||||||
|
await defineCustomField(db, userId, { name: "Mood", type: "text" });
|
||||||
|
await expect(defineCustomField(db, userId, { name: "mood", type: "text" })).rejects.toThrow(ValidationError);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test("validateCustomValues checks every type", () => {
|
||||||
|
const def = (id: string, type: string, options: string[] = []) => ({
|
||||||
|
id,
|
||||||
|
userId: "u",
|
||||||
|
name: id,
|
||||||
|
type: type as never,
|
||||||
|
options,
|
||||||
|
appliesTo: "all" as const,
|
||||||
|
level: "entry" as const,
|
||||||
|
createdAt: "",
|
||||||
|
});
|
||||||
|
const defs = [
|
||||||
|
def("t", "text"),
|
||||||
|
def("n", "number"),
|
||||||
|
def("d", "date"),
|
||||||
|
def("b", "boolean"),
|
||||||
|
def("m", "multi_select", ["a", "b"]),
|
||||||
|
def("u", "url"),
|
||||||
|
def("r", "rating"),
|
||||||
|
];
|
||||||
|
expect(() =>
|
||||||
|
validateCustomValues(defs, { t: "x", n: 1.5, d: "2019", b: true, m: ["a"], u: "https://x.org", r: 80 }),
|
||||||
|
).not.toThrow();
|
||||||
|
expect(() => validateCustomValues(defs, { t: null })).not.toThrow();
|
||||||
|
const bad: Array<[Record<string, unknown>, string]> = [
|
||||||
|
[{ nope: 1 }, "unknown_field"],
|
||||||
|
[{ t: 3 }, "not_text"],
|
||||||
|
[{ n: "3" }, "not_number"],
|
||||||
|
[{ d: "2019-13" }, "not_date"],
|
||||||
|
[{ b: 1 }, "not_boolean"],
|
||||||
|
[{ m: ["c"] }, "unknown_option"],
|
||||||
|
[{ m: "a" }, "not_list"],
|
||||||
|
[{ u: "ftp:x" }, "not_url"],
|
||||||
|
[{ r: 101 }, "out_of_range"],
|
||||||
|
];
|
||||||
|
for (const [values, reason] of bad) {
|
||||||
|
let err: unknown;
|
||||||
|
try {
|
||||||
|
validateCustomValues(defs, values);
|
||||||
|
} catch (e) {
|
||||||
|
err = e;
|
||||||
|
}
|
||||||
|
expect((err as ValidationError).reason).toBe(reason);
|
||||||
|
}
|
||||||
|
});
|
||||||
@@ -0,0 +1,147 @@
|
|||||||
|
import { expect, test } from "bun:test";
|
||||||
|
import { forEachDialect } from "@sttracker/db/testing";
|
||||||
|
import { defineCustomField } from "../src/custom-fields.ts";
|
||||||
|
import {
|
||||||
|
addEngagement,
|
||||||
|
listEngagements,
|
||||||
|
softDeleteEngagement,
|
||||||
|
updateEngagement,
|
||||||
|
} from "../src/engagements.ts";
|
||||||
|
import { moveEntryToWork, softDeleteEntry, upsertEntry } from "../src/entries.ts";
|
||||||
|
import { NotFoundError, ValidationError } from "../src/errors.ts";
|
||||||
|
import { getHistory, restoreVersion } from "../src/history.ts";
|
||||||
|
import { makeUser, makeWork } from "./user-layer-fixtures.ts";
|
||||||
|
|
||||||
|
forEachDialect("engagements", (getDb) => {
|
||||||
|
async function setup(mediaType = "book") {
|
||||||
|
const db = getDb();
|
||||||
|
const userId = await makeUser(db);
|
||||||
|
const entry = await upsertEntry(db, userId, await makeWork(db, { mediaType }), { status: "completed" }, "user");
|
||||||
|
return { db, userId, entry };
|
||||||
|
}
|
||||||
|
|
||||||
|
test("engagement with year precision keeps '2019'", async () => {
|
||||||
|
const { db, userId, entry } = await setup();
|
||||||
|
const eg = await addEngagement(
|
||||||
|
db,
|
||||||
|
userId,
|
||||||
|
entry.id,
|
||||||
|
{ finishedAt: { value: "2019", precision: "year" }, platform: "Kindle" },
|
||||||
|
"user",
|
||||||
|
);
|
||||||
|
expect(eg.finishedAt).toEqual({ value: "2019", precision: "year" });
|
||||||
|
expect(eg.startedAt).toBeNull();
|
||||||
|
expect(eg.fieldSources).toEqual({ finishedAt: "user", platform: "user" });
|
||||||
|
const row = await db.k.selectFrom("engagements").selectAll().where("id", "=", eg.id).executeTakeFirstOrThrow();
|
||||||
|
expect(row.finished_at).toBe("2019");
|
||||||
|
expect(row.finished_precision).toBe("year");
|
||||||
|
expect(row.started_at).toBeNull();
|
||||||
|
expect(row.started_precision).toBe("unknown");
|
||||||
|
const [listed] = await listEngagements(db, userId, entry.id);
|
||||||
|
expect(listed!.finishedAt).toEqual({ value: "2019", precision: "year" });
|
||||||
|
});
|
||||||
|
|
||||||
|
test("partial dates must match their precision", async () => {
|
||||||
|
const { db, userId, entry } = await setup();
|
||||||
|
const bad = [
|
||||||
|
{ value: "2019-03", precision: "year" },
|
||||||
|
{ value: "2019-02-30", precision: "day" },
|
||||||
|
{ value: "", precision: "day" },
|
||||||
|
{ value: "2019", precision: "bogus" },
|
||||||
|
];
|
||||||
|
for (const d of bad) {
|
||||||
|
await expect(addEngagement(db, userId, entry.id, { startedAt: d as never }, "user")).rejects.toThrow(
|
||||||
|
ValidationError,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const ok = await addEngagement(
|
||||||
|
db,
|
||||||
|
userId,
|
||||||
|
entry.id,
|
||||||
|
{ startedAt: { value: "2019-03-04T20:15:00Z", precision: "day" }, finishedAt: { value: "", precision: "unknown" } },
|
||||||
|
"user",
|
||||||
|
);
|
||||||
|
expect(ok.startedAt).toEqual({ value: "2019-03-04T20:15:00Z", precision: "day" });
|
||||||
|
expect(ok.finishedAt).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("updateEngagement touches only patched keys and writes history", async () => {
|
||||||
|
const { db, userId, entry } = await setup();
|
||||||
|
const eg = await addEngagement(db, userId, entry.id, { rating: 70, notes: "great" }, "user");
|
||||||
|
const up = await updateEngagement(db, userId, eg.id, { rating: 90, progress: { page: 120 } }, "plugin:hardcover:c1");
|
||||||
|
expect(up.notes).toBe("great");
|
||||||
|
expect(up.rating).toBe(90);
|
||||||
|
expect(up.progress).toEqual({ page: 120 });
|
||||||
|
expect(up.fieldSources).toEqual({ rating: "plugin:hardcover:c1", notes: "user", progress: "plugin:hardcover:c1" });
|
||||||
|
const [h] = await getHistory(db, userId, "engagement", eg.id);
|
||||||
|
expect(h!.before).toEqual({ rating: 70, progress: {} });
|
||||||
|
expect(h!.after).toEqual({ rating: 90, progress: { page: 120 } });
|
||||||
|
await restoreVersion(db, userId, h!.id);
|
||||||
|
const [back] = await listEngagements(db, userId, entry.id);
|
||||||
|
expect(back!.rating).toBe(70);
|
||||||
|
expect(back!.fieldSources.rating).toBe("user");
|
||||||
|
await expect(updateEngagement(db, userId, eg.id, { rating: -1 }, "user")).rejects.toThrow(ValidationError);
|
||||||
|
await expect(updateEngagement(db, userId, eg.id, { bogus: 1 } as never, "user")).rejects.toThrow(ValidationError);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("engagement custom fields use engagement-level definitions", async () => {
|
||||||
|
const { db, userId, entry } = await setup();
|
||||||
|
const entryLevel = await defineCustomField(db, userId, { name: "Mood", type: "text" });
|
||||||
|
const def = await defineCustomField(db, userId, { name: "Format", type: "single_select", options: ["audio"], level: "engagement" });
|
||||||
|
const eg = await addEngagement(db, userId, entry.id, { customFields: { [def.id]: "audio" } }, "user");
|
||||||
|
expect(eg.customFields).toEqual({ [def.id]: "audio" });
|
||||||
|
await expect(
|
||||||
|
addEngagement(db, userId, entry.id, { customFields: { [entryLevel.id]: "x" } }, "user"),
|
||||||
|
).rejects.toThrow(ValidationError);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("softDeleteEngagement hides it; restore brings it back", async () => {
|
||||||
|
const { db, userId, entry } = await setup();
|
||||||
|
const eg = await addEngagement(db, userId, entry.id, { platform: "Switch" }, "user");
|
||||||
|
await softDeleteEngagement(db, userId, eg.id, "user");
|
||||||
|
expect(await listEngagements(db, userId, entry.id)).toEqual([]);
|
||||||
|
await expect(updateEngagement(db, userId, eg.id, { platform: "PC" }, "user")).rejects.toThrow(NotFoundError);
|
||||||
|
const [del] = await getHistory(db, userId, "engagement", eg.id);
|
||||||
|
await restoreVersion(db, userId, del!.id);
|
||||||
|
expect((await listEngagements(db, userId, entry.id)).map((e) => e.id)).toEqual([eg.id]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("engagements sort by start date, undated last", async () => {
|
||||||
|
const { db, userId, entry } = await setup();
|
||||||
|
const undated = await addEngagement(db, userId, entry.id, {}, "user");
|
||||||
|
const later = await addEngagement(db, userId, entry.id, { startedAt: { value: "2021-05", precision: "month" } }, "user");
|
||||||
|
const early = await addEngagement(db, userId, entry.id, { startedAt: { value: "2019", precision: "year" } }, "user");
|
||||||
|
expect((await listEngagements(db, userId, entry.id)).map((e) => e.id)).toEqual([early.id, later.id, undated.id]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("engagements of a soft-deleted entry cannot be changed", async () => {
|
||||||
|
const { db, userId, entry } = await setup();
|
||||||
|
const eg = await addEngagement(db, userId, entry.id, {}, "user");
|
||||||
|
await softDeleteEntry(db, userId, entry.id, "user");
|
||||||
|
await expect(updateEngagement(db, userId, eg.id, { rating: 1 }, "user")).rejects.toThrow(NotFoundError);
|
||||||
|
await expect(softDeleteEngagement(db, userId, eg.id, "user")).rejects.toThrow(NotFoundError);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("restoring a re-parenting onto a soft-deleted entry is rejected", async () => {
|
||||||
|
const { db, userId, entry } = await setup();
|
||||||
|
const other = await upsertEntry(db, userId, await makeWork(db, { title: "Other" }), {}, "user");
|
||||||
|
const eg = await addEngagement(db, userId, entry.id, {}, "user");
|
||||||
|
await moveEntryToWork(db, userId, entry.id, other.workId, "user");
|
||||||
|
const [h] = await getHistory(db, userId, "engagement", eg.id);
|
||||||
|
expect(h!.before).toEqual({ entryId: entry.id });
|
||||||
|
// the merged-away entry is soft-deleted, so the engagement cannot go back to it
|
||||||
|
const err = await restoreVersion(db, userId, h!.id).catch((e: unknown) => e);
|
||||||
|
expect(err).toBeInstanceOf(ValidationError);
|
||||||
|
expect(err).toMatchObject({ field: "entryId", reason: "target entry is deleted" });
|
||||||
|
});
|
||||||
|
|
||||||
|
test("another user's engagements are not found", async () => {
|
||||||
|
const { db, userId, entry } = await setup();
|
||||||
|
const bob = await makeUser(db, "bob");
|
||||||
|
const eg = await addEngagement(db, userId, entry.id, {}, "user");
|
||||||
|
await expect(addEngagement(db, bob, entry.id, {}, "user")).rejects.toThrow(NotFoundError);
|
||||||
|
await expect(updateEngagement(db, bob, eg.id, { rating: 1 }, "user")).rejects.toThrow(NotFoundError);
|
||||||
|
await expect(softDeleteEngagement(db, bob, eg.id, "user")).rejects.toThrow(NotFoundError);
|
||||||
|
await expect(listEngagements(db, bob, entry.id)).rejects.toThrow(NotFoundError);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,389 @@
|
|||||||
|
import { expect, test } from "bun:test";
|
||||||
|
import { newId } from "@sttracker/db/ids";
|
||||||
|
import { forEachDialect } from "@sttracker/db/testing";
|
||||||
|
import { defineCustomField } from "../src/custom-fields.ts";
|
||||||
|
import { addEngagement, listEngagements } from "../src/engagements.ts";
|
||||||
|
import { NotFoundError, ValidationError } from "../src/errors.ts";
|
||||||
|
import {
|
||||||
|
getEntry,
|
||||||
|
listEntries,
|
||||||
|
moveEntryToWork,
|
||||||
|
quickAdd,
|
||||||
|
softDeleteEntry,
|
||||||
|
upsertEntry,
|
||||||
|
} from "../src/entries.ts";
|
||||||
|
import { getHistory } from "../src/history.ts";
|
||||||
|
import { makeUser, makeWork } from "./user-layer-fixtures.ts";
|
||||||
|
|
||||||
|
forEachDialect("entries", (getDb) => {
|
||||||
|
test("upsertEntry records field_sources and history", async () => {
|
||||||
|
const db = getDb();
|
||||||
|
const userId = await makeUser(db);
|
||||||
|
const workId = await makeWork(db);
|
||||||
|
const created = await upsertEntry(db, userId, workId, { status: "planned", tags: ["sci-fi"] }, "user");
|
||||||
|
expect(created.status).toBe("planned");
|
||||||
|
expect(created.visibility).toBe("private");
|
||||||
|
expect(created.fieldSources).toEqual({ status: "user", tags: "user" });
|
||||||
|
|
||||||
|
const updated = await upsertEntry(db, userId, workId, { rating: 80, status: "completed" }, "plugin:trakt:c1");
|
||||||
|
expect(updated.id).toBe(created.id);
|
||||||
|
expect(updated.rating).toBe(80);
|
||||||
|
expect(updated.tags).toEqual(["sci-fi"]);
|
||||||
|
expect(updated.fieldSources).toEqual({ status: "plugin:trakt:c1", tags: "user", rating: "plugin:trakt:c1" });
|
||||||
|
|
||||||
|
const history = await getHistory(db, userId, "entry", created.id);
|
||||||
|
expect(history).toHaveLength(2);
|
||||||
|
expect(history[0]!.before).toEqual({ status: "planned", rating: null });
|
||||||
|
expect(history[0]!.after).toEqual({ status: "completed", rating: 80 });
|
||||||
|
expect(history[0]!.actor).toBe("plugin:trakt:c1");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("upsertEntry works inside a caller's transaction", async () => {
|
||||||
|
const db = getDb();
|
||||||
|
const userId = await makeUser(db);
|
||||||
|
const workId = await makeWork(db);
|
||||||
|
await expect(
|
||||||
|
db.k.transaction().execute(async (tx) => {
|
||||||
|
await upsertEntry(db, userId, workId, { status: "planned" }, "user", tx);
|
||||||
|
throw new Error("rollback");
|
||||||
|
}),
|
||||||
|
).rejects.toThrow("rollback");
|
||||||
|
expect((await listEntries(db, userId, {})).items).toHaveLength(0);
|
||||||
|
const e = await db.k.transaction().execute((tx) => upsertEntry(db, userId, workId, { favorite: true }, "user", tx));
|
||||||
|
expect(e.favorite).toBe(true);
|
||||||
|
expect(e.status).toBe("none");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("visibility default comes from user preferences", async () => {
|
||||||
|
const db = getDb();
|
||||||
|
const userId = await makeUser(db, "pub", { defaultVisibility: "public" });
|
||||||
|
const e = await upsertEntry(db, userId, await makeWork(db), {}, "user");
|
||||||
|
expect(e.visibility).toBe("public");
|
||||||
|
expect(e.fieldSources).toEqual({});
|
||||||
|
});
|
||||||
|
|
||||||
|
test("rating 101 rejected", async () => {
|
||||||
|
const db = getDb();
|
||||||
|
const userId = await makeUser(db);
|
||||||
|
const workId = await makeWork(db);
|
||||||
|
await expect(upsertEntry(db, userId, workId, { rating: 101 }, "user")).rejects.toThrow(ValidationError);
|
||||||
|
await expect(upsertEntry(db, userId, workId, { rating: 7.5 }, "user")).rejects.toThrow(ValidationError);
|
||||||
|
await expect(upsertEntry(db, userId, workId, { status: "done" as never }, "user")).rejects.toThrow(
|
||||||
|
ValidationError,
|
||||||
|
);
|
||||||
|
expect((await listEntries(db, userId, {})).items).toHaveLength(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("custom field values are validated against the user's entry definitions", async () => {
|
||||||
|
const db = getDb();
|
||||||
|
const userId = await makeUser(db);
|
||||||
|
const workId = await makeWork(db, { mediaType: "book" });
|
||||||
|
const def = await defineCustomField(db, userId, { name: "Mood", type: "single_select", options: ["cosy"] });
|
||||||
|
const gameOnly = await defineCustomField(db, userId, { name: "Hours", type: "number", appliesTo: ["game"] });
|
||||||
|
const e = await upsertEntry(db, userId, workId, { customFields: { [def.id]: "cosy" } }, "user");
|
||||||
|
expect(e.customFields).toEqual({ [def.id]: "cosy" });
|
||||||
|
await expect(upsertEntry(db, userId, workId, { customFields: { [def.id]: "grim" } }, "user")).rejects.toThrow(
|
||||||
|
ValidationError,
|
||||||
|
);
|
||||||
|
await expect(upsertEntry(db, userId, workId, { customFields: { [gameOnly.id]: 3 } }, "user")).rejects.toThrow(
|
||||||
|
ValidationError,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("quickAdd creates draft entry", async () => {
|
||||||
|
const db = getDb();
|
||||||
|
const userId = await makeUser(db);
|
||||||
|
const view = await quickAdd(db, userId, { title: "Zine I found" });
|
||||||
|
expect(view.draft).toBe(true);
|
||||||
|
expect(view.work.title).toBe("Zine I found");
|
||||||
|
expect(view.work.mediaType).toBe("other");
|
||||||
|
expect(view.status).toBe("none");
|
||||||
|
const work = await db.k.selectFrom("works").selectAll().where("id", "=", view.workId).executeTakeFirstOrThrow();
|
||||||
|
expect(work.owner_user_id).toBe(userId);
|
||||||
|
|
||||||
|
const shared = await upsertEntry(db, userId, await makeWork(db, { externalIds: { isbn13: "1" } }), {}, "user");
|
||||||
|
expect((await getEntry(db, userId, shared.id)).draft).toBe(false);
|
||||||
|
expect((await listEntries(db, userId, { draft: true })).items.map((i) => i.id)).toEqual([view.id]);
|
||||||
|
await expect(quickAdd(db, userId, { title: " " })).rejects.toThrow(ValidationError);
|
||||||
|
await expect(quickAdd(db, userId, { title: "x", mediaType: "zine" as never })).rejects.toThrow(ValidationError);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("listEntries rolls up children", async () => {
|
||||||
|
const db = getDb();
|
||||||
|
const userId = await makeUser(db);
|
||||||
|
const show = await makeWork(db, { title: "Severance", mediaType: "show" });
|
||||||
|
const showEntry = await upsertEntry(db, userId, show, { status: "in_progress" }, "user");
|
||||||
|
for (const status of ["completed", "completed", "in_progress"] as const) {
|
||||||
|
const ep = await makeWork(db, { title: "Episode", mediaType: "episode", parentId: show });
|
||||||
|
await upsertEntry(db, userId, ep, { status }, "user");
|
||||||
|
}
|
||||||
|
const lone = await upsertEntry(db, userId, await makeWork(db, { title: "Dune" }), {}, "user");
|
||||||
|
|
||||||
|
const top = await listEntries(db, userId, {});
|
||||||
|
expect(top.items.map((i) => i.id).sort()).toEqual([showEntry.id, lone.id].sort());
|
||||||
|
const showView = top.items.find((i) => i.id === showEntry.id)!;
|
||||||
|
expect(showView.childSummary).toEqual({ total: 3, completed: 2 });
|
||||||
|
expect(top.items.find((i) => i.id === lone.id)!.childSummary).toBeNull();
|
||||||
|
|
||||||
|
const children = await listEntries(db, userId, { parentId: show });
|
||||||
|
expect(children.items).toHaveLength(3);
|
||||||
|
expect(children.items.every((i) => i.work.mediaType === "episode")).toBe(true);
|
||||||
|
|
||||||
|
// grandchildren: show → season (no entry) → episodes still roll up to the show
|
||||||
|
const show2 = await makeWork(db, { title: "The Expanse", mediaType: "show" });
|
||||||
|
const show2Entry = await upsertEntry(db, userId, show2, {}, "user");
|
||||||
|
const season = await makeWork(db, { title: "Season 1", mediaType: "season", parentId: show2 });
|
||||||
|
for (const status of ["completed", "planned"] as const) {
|
||||||
|
const ep = await makeWork(db, { title: "Episode", mediaType: "episode", parentId: season });
|
||||||
|
await upsertEntry(db, userId, ep, { status }, "user");
|
||||||
|
}
|
||||||
|
const top2 = await listEntries(db, userId, {});
|
||||||
|
expect(top2.items.map((i) => i.id).sort()).toEqual([showEntry.id, lone.id, show2Entry.id].sort());
|
||||||
|
expect(top2.items.find((i) => i.id === show2Entry.id)!.childSummary).toEqual({ total: 2, completed: 1 });
|
||||||
|
|
||||||
|
// drilling into the show lists the episodes, since the season between them has no entry
|
||||||
|
const drill = await listEntries(db, userId, { parentId: show2 });
|
||||||
|
expect(drill.items.map((i) => i.work.mediaType)).toEqual(["episode", "episode"]);
|
||||||
|
expect(await listEntries(db, userId, { parentId: show2, q: "nothing matches" })).toEqual({ items: [], nextCursor: null });
|
||||||
|
|
||||||
|
// once the season has an entry, the show lists the season and the season lists the episodes
|
||||||
|
const seasonEntry = await upsertEntry(db, userId, season, { status: "in_progress" }, "user");
|
||||||
|
expect((await listEntries(db, userId, { parentId: show2 })).items.map((i) => i.id)).toEqual([seasonEntry.id]);
|
||||||
|
const seasonDrill = await listEntries(db, userId, { parentId: season });
|
||||||
|
expect(seasonDrill.items.map((i) => i.work.mediaType)).toEqual(["episode", "episode"]);
|
||||||
|
expect(seasonDrill.items.find((i) => i.id === seasonEntry.id)).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("orphaned child entries are top level until an ancestor has an entry", async () => {
|
||||||
|
const db = getDb();
|
||||||
|
const userId = await makeUser(db);
|
||||||
|
const show = await makeWork(db, { title: "Severance", mediaType: "show" });
|
||||||
|
const season = await makeWork(db, { title: "Season 1", mediaType: "season", parentId: show });
|
||||||
|
const ep = await makeWork(db, { title: "Good News About Hell", mediaType: "episode", parentId: season });
|
||||||
|
const epEntry = await upsertEntry(db, userId, ep, { status: "completed" }, "user");
|
||||||
|
expect((await listEntries(db, userId, {})).items.map((i) => i.id)).toEqual([epEntry.id]);
|
||||||
|
|
||||||
|
// another user's show entry does not hide it
|
||||||
|
const bob = await makeUser(db, "bob");
|
||||||
|
await upsertEntry(db, bob, show, {}, "user");
|
||||||
|
expect((await listEntries(db, userId, {})).items.map((i) => i.id)).toEqual([epEntry.id]);
|
||||||
|
|
||||||
|
const showEntry = await upsertEntry(db, userId, show, {}, "user");
|
||||||
|
expect((await listEntries(db, userId, {})).items.map((i) => i.id)).toEqual([showEntry.id]);
|
||||||
|
// a soft-deleted ancestor entry does not hide it
|
||||||
|
await softDeleteEntry(db, userId, showEntry.id, "user");
|
||||||
|
expect((await listEntries(db, userId, {})).items.map((i) => i.id)).toEqual([epEntry.id]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("title pagination does not skip non-ASCII titles", async () => {
|
||||||
|
const db = getDb();
|
||||||
|
const userId = await makeUser(db);
|
||||||
|
const titles = ["Éclair", "Ölund", "Zeta", "alpha", "日本", "éclair 2", "BETA"];
|
||||||
|
for (const title of titles) await upsertEntry(db, userId, await makeWork(db, { title }), {}, "user");
|
||||||
|
const seen: string[] = [];
|
||||||
|
let cursor: string | undefined;
|
||||||
|
for (let i = 0; i < 20; i++) {
|
||||||
|
const page = await listEntries(db, userId, { sort: "title", limit: 1, cursor });
|
||||||
|
seen.push(...page.items.map((x) => x.work.title));
|
||||||
|
if (!page.nextCursor) break;
|
||||||
|
cursor = page.nextCursor;
|
||||||
|
}
|
||||||
|
expect(seen).toHaveLength(titles.length);
|
||||||
|
expect([...seen].sort()).toEqual([...titles].sort());
|
||||||
|
});
|
||||||
|
|
||||||
|
test("concurrent upserts of a new entry yield one entry with both patches", async () => {
|
||||||
|
const db = getDb();
|
||||||
|
const userId = await makeUser(db);
|
||||||
|
const workId = await makeWork(db);
|
||||||
|
const [a, b] = await Promise.all([
|
||||||
|
upsertEntry(db, userId, workId, { rating: 70 }, "plugin:trakt:c1"),
|
||||||
|
upsertEntry(db, userId, workId, { tags: ["cosy"] }, "user"),
|
||||||
|
]);
|
||||||
|
expect(a.id).toBe(b.id);
|
||||||
|
const rows = await db.k.selectFrom("entries").selectAll().where("user_id", "=", userId).execute();
|
||||||
|
expect(rows).toHaveLength(1);
|
||||||
|
const view = await getEntry(db, userId, a.id);
|
||||||
|
expect(view.rating).toBe(70);
|
||||||
|
expect(view.tags).toEqual(["cosy"]);
|
||||||
|
expect(view.fieldSources).toEqual({ rating: "plugin:trakt:c1", tags: "user" });
|
||||||
|
});
|
||||||
|
|
||||||
|
test("listEntries filters, sorts and paginates", async () => {
|
||||||
|
const db = getDb();
|
||||||
|
const userId = await makeUser(db);
|
||||||
|
const other = await makeUser(db, "bob");
|
||||||
|
const titles = ["Alpha", "bravo", "Charlie", "delta", "Echo"];
|
||||||
|
const ids: string[] = [];
|
||||||
|
for (const [i, title] of titles.entries()) {
|
||||||
|
const w = await makeWork(db, { title, mediaType: i % 2 ? "game" : "book" });
|
||||||
|
const e = await upsertEntry(
|
||||||
|
db,
|
||||||
|
userId,
|
||||||
|
w,
|
||||||
|
{ rating: i === 4 ? null : i * 10, tags: i < 2 ? ["Cosy"] : [], status: i === 0 ? "completed" : "planned" },
|
||||||
|
"user",
|
||||||
|
);
|
||||||
|
ids.push(e.id);
|
||||||
|
await upsertEntry(db, other, w, { status: "completed" }, "user");
|
||||||
|
}
|
||||||
|
// updated sort: newest first, keyset pagination
|
||||||
|
const p1 = await listEntries(db, userId, { limit: 2 });
|
||||||
|
expect(p1.items).toHaveLength(2);
|
||||||
|
expect(p1.nextCursor).not.toBeNull();
|
||||||
|
const p2 = await listEntries(db, userId, { limit: 2, cursor: p1.nextCursor! });
|
||||||
|
const p3 = await listEntries(db, userId, { limit: 2, cursor: p2.nextCursor! });
|
||||||
|
expect(p3.nextCursor).toBeNull();
|
||||||
|
expect([...p1.items, ...p2.items, ...p3.items].map((i) => i.id)).toEqual([...ids].reverse());
|
||||||
|
|
||||||
|
const byTitle = await listEntries(db, userId, { sort: "title", limit: 3 });
|
||||||
|
const byTitle2 = await listEntries(db, userId, { sort: "title", limit: 3, cursor: byTitle.nextCursor! });
|
||||||
|
expect([...byTitle.items, ...byTitle2.items].map((i) => i.work.title)).toEqual(titles);
|
||||||
|
|
||||||
|
const byRating = await listEntries(db, userId, { sort: "rating", limit: 2 });
|
||||||
|
const byRating2 = await listEntries(db, userId, { sort: "rating", limit: 10, cursor: byRating.nextCursor! });
|
||||||
|
expect([...byRating.items, ...byRating2.items].map((i) => i.rating)).toEqual([30, 20, 10, 0, null]);
|
||||||
|
|
||||||
|
expect((await listEntries(db, userId, { mediaType: "game" })).items).toHaveLength(2);
|
||||||
|
expect((await listEntries(db, userId, { status: "completed" })).items.map((i) => i.id)).toEqual([ids[0]!]);
|
||||||
|
expect((await listEntries(db, userId, { tag: "cosy" })).items).toHaveLength(2);
|
||||||
|
expect((await listEntries(db, userId, { q: "RAV" })).items.map((i) => i.id)).toEqual([ids[1]!]);
|
||||||
|
expect((await listEntries(db, userId, { q: "100%" })).items).toHaveLength(0);
|
||||||
|
await expect(listEntries(db, userId, { cursor: "garbage" })).rejects.toThrow(ValidationError);
|
||||||
|
await expect(listEntries(db, userId, { sort: "title", cursor: p1.nextCursor! })).rejects.toThrow(
|
||||||
|
ValidationError,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("overrides apply to the work view", async () => {
|
||||||
|
const db = getDb();
|
||||||
|
const userId = await makeUser(db);
|
||||||
|
const workId = await makeWork(db, { title: "Dune", year: 1965 });
|
||||||
|
const e = await upsertEntry(db, userId, workId, { overrides: { title: "Dune (my edition)", year: 1990 } }, "user");
|
||||||
|
const view = await getEntry(db, userId, e.id);
|
||||||
|
expect(view.work.title).toBe("Dune (my edition)");
|
||||||
|
expect(view.work.year).toBe(1990);
|
||||||
|
expect(view.work.coverUrl).toBe("");
|
||||||
|
await expect(upsertEntry(db, userId, workId, { overrides: { colour: "red" } as never }, "user")).rejects.toThrow(
|
||||||
|
ValidationError,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("other users' entries and private works are not found", async () => {
|
||||||
|
const db = getDb();
|
||||||
|
const ada = await makeUser(db);
|
||||||
|
const bob = await makeUser(db, "bob");
|
||||||
|
const draft = await quickAdd(db, ada, { title: "Secret" });
|
||||||
|
await expect(getEntry(db, bob, draft.id)).rejects.toThrow(NotFoundError);
|
||||||
|
await expect(upsertEntry(db, bob, draft.workId, {}, "user")).rejects.toThrow(NotFoundError);
|
||||||
|
await expect(softDeleteEntry(db, bob, draft.id, "user")).rejects.toThrow(NotFoundError);
|
||||||
|
await expect(moveEntryToWork(db, bob, draft.id, await makeWork(db), "user")).rejects.toThrow(NotFoundError);
|
||||||
|
await expect(getEntry(db, ada, newId())).rejects.toThrow(NotFoundError);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("softDeleteEntry hides the entry and upsert revives it", async () => {
|
||||||
|
const db = getDb();
|
||||||
|
const userId = await makeUser(db);
|
||||||
|
const workId = await makeWork(db);
|
||||||
|
const e = await upsertEntry(db, userId, workId, { rating: 40 }, "user");
|
||||||
|
await softDeleteEntry(db, userId, e.id, "user");
|
||||||
|
await expect(getEntry(db, userId, e.id)).rejects.toThrow(NotFoundError);
|
||||||
|
expect((await listEntries(db, userId, {})).items).toHaveLength(0);
|
||||||
|
const again = await upsertEntry(db, userId, workId, { status: "planned" }, "user");
|
||||||
|
expect(again.id).toBe(e.id);
|
||||||
|
expect(again.deletedAt).toBeNull();
|
||||||
|
expect(again.rating).toBe(40);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("moveEntryToWork leaves alias", async () => {
|
||||||
|
const db = getDb();
|
||||||
|
const userId = await makeUser(db);
|
||||||
|
const draft = await quickAdd(db, userId, { title: "Dune??", mediaType: "book" });
|
||||||
|
const shared = await makeWork(db, { title: "Dune", externalIds: { isbn13: "9780441013593" } });
|
||||||
|
const moved = await moveEntryToWork(db, userId, draft.id, shared, "user");
|
||||||
|
expect(moved.id).toBe(draft.id);
|
||||||
|
expect(moved.workId).toBe(shared);
|
||||||
|
|
||||||
|
const alias = await db.k
|
||||||
|
.selectFrom("work_aliases")
|
||||||
|
.selectAll()
|
||||||
|
.where("user_id", "=", userId)
|
||||||
|
.executeTakeFirstOrThrow();
|
||||||
|
expect(alias.from_work_id).toBe(draft.workId);
|
||||||
|
expect(alias.to_work_id).toBe(shared);
|
||||||
|
const old = await db.k.selectFrom("works").selectAll().where("id", "=", draft.workId).executeTakeFirstOrThrow();
|
||||||
|
expect(old.merged_into_id).toBe(shared);
|
||||||
|
expect((await getEntry(db, userId, draft.id)).draft).toBe(false);
|
||||||
|
const [h] = await getHistory(db, userId, "entry", draft.id);
|
||||||
|
expect(h!.after).toEqual({ workId: shared });
|
||||||
|
});
|
||||||
|
|
||||||
|
test("moveEntryToWork merges into an existing entry on the target", async () => {
|
||||||
|
const db = getDb();
|
||||||
|
const userId = await makeUser(db);
|
||||||
|
const a = await makeWork(db, { title: "A" });
|
||||||
|
const b = await makeWork(db, { title: "B" });
|
||||||
|
const src = await upsertEntry(db, userId, a, { rating: 70, tags: ["x"] }, "user");
|
||||||
|
const dst = await upsertEntry(db, userId, b, { status: "completed", tags: ["y"] }, "user");
|
||||||
|
const merged = await moveEntryToWork(db, userId, src.id, b, "user");
|
||||||
|
expect(merged.id).toBe(dst.id);
|
||||||
|
expect(merged.status).toBe("completed");
|
||||||
|
expect(merged.rating).toBe(70);
|
||||||
|
expect(merged.tags.sort()).toEqual(["x", "y"]);
|
||||||
|
await expect(getEntry(db, userId, src.id)).rejects.toThrow(NotFoundError);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("merge keeps 'user' field_sources on combined fields", async () => {
|
||||||
|
const db = getDb();
|
||||||
|
const userId = await makeUser(db);
|
||||||
|
const a = await makeWork(db, { title: "A" });
|
||||||
|
const b = await makeWork(db, { title: "B" });
|
||||||
|
const src = await upsertEntry(db, userId, a, { tags: ["x"], overrides: { year: 1 } }, "plugin:trakt:c1");
|
||||||
|
await upsertEntry(db, userId, b, { tags: ["y"], status: "completed" }, "user");
|
||||||
|
await upsertEntry(db, userId, b, { overrides: { title: "B!" } }, "plugin:hardcover:c2");
|
||||||
|
const merged = await moveEntryToWork(db, userId, src.id, b, "plugin:steam:c3");
|
||||||
|
expect(merged.tags.sort()).toEqual(["x", "y"]);
|
||||||
|
expect(merged.overrides).toEqual({ year: 1, title: "B!" });
|
||||||
|
expect(merged.fieldSources).toEqual({ tags: "user", status: "user", overrides: "plugin:hardcover:c2" });
|
||||||
|
});
|
||||||
|
|
||||||
|
test("merge into a soft-deleted entry carries the moved field_sources as-is", async () => {
|
||||||
|
const db = getDb();
|
||||||
|
const userId = await makeUser(db);
|
||||||
|
const a = await makeWork(db, { title: "A" });
|
||||||
|
const b = await makeWork(db, { title: "B" });
|
||||||
|
const src = await upsertEntry(db, userId, a, { rating: 60, review: "mine" }, "user");
|
||||||
|
const dst = await upsertEntry(db, userId, b, { status: "dropped", tags: ["old"] }, "plugin:trakt:c1");
|
||||||
|
await softDeleteEntry(db, userId, dst.id, "user");
|
||||||
|
const merged = await moveEntryToWork(db, userId, src.id, b, "plugin:trakt:c2");
|
||||||
|
expect(merged.id).toBe(dst.id);
|
||||||
|
expect(merged.deletedAt).toBeNull();
|
||||||
|
expect(merged.rating).toBe(60);
|
||||||
|
expect(merged.status).toBe("none");
|
||||||
|
expect(merged.tags).toEqual([]);
|
||||||
|
expect(merged.fieldSources).toEqual({ rating: "user", review: "user" });
|
||||||
|
});
|
||||||
|
|
||||||
|
test("merge concatenates differing text, records the dropped rating and moves engagements with history", async () => {
|
||||||
|
const db = getDb();
|
||||||
|
const userId = await makeUser(db);
|
||||||
|
const a = await makeWork(db, { title: "A" });
|
||||||
|
const b = await makeWork(db, { title: "B" });
|
||||||
|
const src = await upsertEntry(db, userId, a, { review: "from A", privateNotes: "same", rating: 40 }, "user");
|
||||||
|
const dst = await upsertEntry(db, userId, b, { review: "from B", privateNotes: "same", rating: 90 }, "user");
|
||||||
|
const eg = await addEngagement(db, userId, src.id, { platform: "Kindle" }, "user");
|
||||||
|
const merged = await moveEntryToWork(db, userId, src.id, b, "user");
|
||||||
|
expect(merged.review).toBe("from B\n\n---\n\nfrom A");
|
||||||
|
expect(merged.privateNotes).toBe("same");
|
||||||
|
expect(merged.rating).toBe(90);
|
||||||
|
const [h] = await getHistory(db, userId, "entry", dst.id);
|
||||||
|
expect(h!.before.rating).toBe(40);
|
||||||
|
expect(h!.after.rating).toBe(90);
|
||||||
|
expect(h!.before.review).toBe("from B");
|
||||||
|
|
||||||
|
expect((await listEngagements(db, userId, dst.id)).map((e) => e.id)).toEqual([eg.id]);
|
||||||
|
const [egh] = await getHistory(db, userId, "engagement", eg.id);
|
||||||
|
expect(egh!.before).toEqual({ entryId: src.id });
|
||||||
|
expect(egh!.after).toEqual({ entryId: dst.id });
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,224 @@
|
|||||||
|
import { afterEach, beforeEach, expect, test } from "bun:test";
|
||||||
|
import { mkdtemp, rm } from "node:fs/promises";
|
||||||
|
import { tmpdir } from "node:os";
|
||||||
|
import { join } from "node:path";
|
||||||
|
import { strFromU8, unzipSync, zipSync, strToU8 } from "fflate";
|
||||||
|
import { forEachDialect } from "@sttracker/db/testing";
|
||||||
|
import { storeAttachment } from "../src/attachments.ts";
|
||||||
|
import { ImportError } from "../src/errors.ts";
|
||||||
|
import { moveEntryToWork, quickAdd, upsertEntry } from "../src/entries.ts";
|
||||||
|
import { exportUserBundle, exportUserCsv, importUserBundle } from "../src/export.ts";
|
||||||
|
import { makeUser, makeWork } from "./user-layer-fixtures.ts";
|
||||||
|
|
||||||
|
forEachDialect("export bundle", (getDb) => {
|
||||||
|
let dir: string;
|
||||||
|
let dir2: string;
|
||||||
|
beforeEach(async () => {
|
||||||
|
dir = await mkdtemp(join(tmpdir(), "sttracker-exp-"));
|
||||||
|
dir2 = await mkdtemp(join(tmpdir(), "sttracker-exp-"));
|
||||||
|
});
|
||||||
|
afterEach(async () => {
|
||||||
|
await rm(dir, { recursive: true, force: true });
|
||||||
|
await rm(dir2, { recursive: true, force: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
async function seed(db: ReturnType<typeof getDb>, u: string) {
|
||||||
|
const shared = await makeWork(db, { title: "Dune", mediaType: "book", year: 1965, externalIds: { hardcover: "dune" } });
|
||||||
|
const parent = await makeWork(db, { title: "Show", mediaType: "tv", externalIds: { trakt: "show" } });
|
||||||
|
const child = await makeWork(db, { title: "Pilot", mediaType: "tv", parentId: parent, externalIds: { trakt: "pilot" } });
|
||||||
|
const priv = await makeWork(db, { title: "My Zine", mediaType: "book", ownerUserId: u });
|
||||||
|
const now = "2024-01-02T03:04:05.000Z";
|
||||||
|
const ent = async (id: string, workId: string, extra: object = {}) =>
|
||||||
|
db.k.insertInto("entries").values({
|
||||||
|
id, user_id: u, work_id: workId, status: "completed", rating: 85, review: "great", private_notes: null, favorite: 1,
|
||||||
|
tags: '["a","b"]', custom_fields: "{}", visibility: "private", overrides: "{}", media_type_override: null,
|
||||||
|
field_sources: '{"rating":"user"}', created_at: now, updated_at: now, deleted_at: null, ...extra,
|
||||||
|
}).execute();
|
||||||
|
const defId = crypto.randomUUID();
|
||||||
|
await db.k.insertInto("custom_field_definitions").values({ id: defId, user_id: u, name: "Shelf", type: "text", options: "[]", applies_to: '"all"', level: "entry", created_at: now }).execute();
|
||||||
|
const e1 = crypto.randomUUID(), e2 = crypto.randomUUID(), e3 = crypto.randomUUID(), e4 = crypto.randomUUID();
|
||||||
|
await ent(e1, shared, { overrides: '{"title":"Dune X"}', visibility: "public", custom_fields: JSON.stringify({ [defId]: "top shelf" }) });
|
||||||
|
await ent(e2, child);
|
||||||
|
await ent(e3, priv, { rating: null });
|
||||||
|
await ent(e4, parent, { deleted_at: now });
|
||||||
|
const eng = crypto.randomUUID();
|
||||||
|
await db.k.insertInto("engagements").values({
|
||||||
|
id: eng, entry_id: e1, started_at: "1999-05", started_precision: "month", finished_at: "2000", finished_precision: "year",
|
||||||
|
progress: "{}", platform: "kindle", rating: 70, notes: "n", custom_fields: "{}", field_sources: "{}", created_at: now, updated_at: now, deleted_at: null,
|
||||||
|
}).execute();
|
||||||
|
await db.k.insertInto("copies").values({ id: crypto.randomUUID(), user_id: u, work_id: shared, title_hint: null, format: "hardcover", acquired_from: "shop", acquired_at: "2020-03", acquired_precision: "month", price_cents: 1299, currency: "EUR", notes: null, custom_fields: "{}", source_ref: null, created_at: now, updated_at: now, deleted_at: null }).execute();
|
||||||
|
await db.k.insertInto("listens").values({ id: crypto.randomUUID(), user_id: u, listened_at: now, artist: "Artist", track: "Track", album: null, duration_ms: 1000, mbids: "{}", work_id: null, source: "lastfm", source_key: "k1", created_at: now }).execute();
|
||||||
|
const grp = crypto.randomUUID();
|
||||||
|
await db.k.insertInto("lists").values({ id: grp, user_id: u, name: "Grouped", description: "", kind: "grouping", visibility: "public", rating: 90, review: "nice set", created_at: now, updated_at: now, deleted_at: null }).execute();
|
||||||
|
const list = crypto.randomUUID();
|
||||||
|
await db.k.insertInto("lists").values({ id: list, user_id: u, name: "Faves", description: "d", kind: "list", visibility: "private", rating: null, review: null, created_at: now, updated_at: now, deleted_at: null }).execute();
|
||||||
|
await db.k.insertInto("list_items").values({ id: crypto.randomUUID(), list_id: list, work_id: shared, position: 0, note: "first" }).execute();
|
||||||
|
await db.k.insertInto("user_history").values({ id: crypto.randomUUID(), user_id: u, entity: "entry", entity_id: e1, before: "{}", after: '{"rating":85}', actor: "user", created_at: now }).execute();
|
||||||
|
await db.k.insertInto("user_history").values({ id: crypto.randomUUID(), user_id: u, entity: "entry", entity_id: e1, before: "{}", after: JSON.stringify({ workId: shared, note: "moved" }), actor: "user", created_at: now }).execute();
|
||||||
|
const att = await storeAttachment(db, dir, u, { bytes: new TextEncoder().encode("hello"), mime: "text/plain", filename: "n.txt", caption: "a caption" }, { entity: "entry", entityId: e1 });
|
||||||
|
await db.k.insertInto("attachment_links").values({ id: crypto.randomUUID(), attachment_id: att.id, entity: "engagement", entity_id: eng }).execute();
|
||||||
|
return { e1, shared, defId };
|
||||||
|
}
|
||||||
|
|
||||||
|
test("export then import into fresh user reproduces entries, ratings, engagements with precision, lists and attachments", async () => {
|
||||||
|
const db = getDb();
|
||||||
|
const u = await makeUser(db, "ada");
|
||||||
|
const { defId } = await seed(db, u);
|
||||||
|
// A second user's data must never leak into ada's bundle.
|
||||||
|
const other = await makeUser(db, "eve");
|
||||||
|
const otherWork = await makeWork(db, { title: "Other Zine", mediaType: "book", ownerUserId: other });
|
||||||
|
const oe = crypto.randomUUID();
|
||||||
|
await db.k.insertInto("entries").values({ id: oe, user_id: other, work_id: otherWork, status: "planned", rating: null, review: null, private_notes: "secret", favorite: 0, tags: "[]", custom_fields: "{}", visibility: "private", overrides: "{}", media_type_override: null, field_sources: "{}", created_at: "2024-01-01T00:00:00.000Z", updated_at: "2024-01-01T00:00:00.000Z", deleted_at: null }).execute();
|
||||||
|
await storeAttachment(db, dir, other, { bytes: new TextEncoder().encode("hello"), mime: "text/plain", filename: "eve.txt" }, { entity: "entry", entityId: oe });
|
||||||
|
const zip = await exportUserBundle(db, dir, u);
|
||||||
|
const files = unzipSync(zip);
|
||||||
|
const allText = Object.entries(files).filter(([n]) => n.endsWith(".jsonl")).map(([, b]) => strFromU8(b)).join("\n");
|
||||||
|
expect(allText).not.toContain("Other Zine");
|
||||||
|
expect(allText).not.toContain("secret");
|
||||||
|
expect(strFromU8(files["attachments.jsonl"]!).trim().split("\n")).toHaveLength(1);
|
||||||
|
|
||||||
|
const v = await makeUser(db, "bob");
|
||||||
|
expect(await importUserBundle(db, dir2, v, zip)).toEqual({ works: 4, entries: 4 });
|
||||||
|
expect(await db.k.selectFrom("entries").select("id").where("user_id", "=", other).execute()).toHaveLength(1);
|
||||||
|
|
||||||
|
const rows = await db.k.selectFrom("entries").innerJoin("works", "works.id", "entries.work_id").select(["works.title", "entries.rating", "entries.favorite", "entries.field_sources", "entries.deleted_at", "entries.created_at", "works.owner_user_id", "works.parent_id", "works.id as wid"]).where("entries.user_id", "=", v).execute();
|
||||||
|
const byTitle = Object.fromEntries(rows.map((r) => [r.title, r]));
|
||||||
|
expect(rows).toHaveLength(4);
|
||||||
|
expect(byTitle["Dune"]!.rating).toBe(85);
|
||||||
|
expect(byTitle["Dune"]!.favorite).toBe(1);
|
||||||
|
expect(byTitle["Dune"]!.field_sources).toBe('{"rating":"user"}');
|
||||||
|
expect(byTitle["Dune"]!.created_at).toBe("2024-01-02T03:04:05.000Z");
|
||||||
|
expect(byTitle["Show"]!.deleted_at).not.toBeNull();
|
||||||
|
expect(byTitle["My Zine"]!.owner_user_id).toBe(v);
|
||||||
|
expect(byTitle["Pilot"]!.parent_id).toBe(byTitle["Show"]!.wid);
|
||||||
|
// shared works were re-matched, not duplicated
|
||||||
|
expect(await db.k.selectFrom("works").select("id").where("title", "=", "Dune").execute()).toHaveLength(1);
|
||||||
|
|
||||||
|
const eng = await db.k.selectFrom("engagements").innerJoin("entries", "entries.id", "engagements.entry_id").selectAll("engagements").where("entries.user_id", "=", v).execute();
|
||||||
|
expect(eng).toHaveLength(1);
|
||||||
|
expect(eng[0]).toMatchObject({ started_at: "1999-05", started_precision: "month", finished_precision: "year", platform: "kindle" });
|
||||||
|
|
||||||
|
const lists = await db.k.selectFrom("lists").selectAll().where("user_id", "=", v).execute();
|
||||||
|
expect(lists).toHaveLength(2);
|
||||||
|
const items = await db.k.selectFrom("list_items").selectAll().where("list_id", "=", lists.find((l) => l.name === "Faves")!.id).execute();
|
||||||
|
expect(items).toMatchObject([{ note: "first", work_id: byTitle["Dune"]!.wid }]);
|
||||||
|
|
||||||
|
const hist = await db.k.selectFrom("user_history").selectAll().where("user_id", "=", v).execute();
|
||||||
|
expect(hist).toHaveLength(2);
|
||||||
|
const dune = await db.k.selectFrom("entries").select("id").where("user_id", "=", v).where("work_id", "=", byTitle["Dune"]!.wid).executeTakeFirstOrThrow();
|
||||||
|
expect(hist.every((h) => h.entity_id === dune.id)).toBe(true);
|
||||||
|
|
||||||
|
const atts = await db.k.selectFrom("attachments").innerJoin("attachment_links", "attachment_links.attachment_id", "attachments.id").selectAll("attachments").select("attachment_links.entity_id").where("attachments.user_id", "=", v).execute();
|
||||||
|
expect(atts).toHaveLength(2);
|
||||||
|
expect(atts.every((a) => a.caption === "a caption")).toBe(true);
|
||||||
|
const engRow = eng[0]!;
|
||||||
|
expect(atts.map((a) => a.entity_id).sort()).toEqual([dune.id, engRow.id].sort());
|
||||||
|
|
||||||
|
const full = await db.k.selectFrom("entries").selectAll().where("id", "=", dune.id).executeTakeFirstOrThrow();
|
||||||
|
expect(full).toMatchObject({ visibility: "public", overrides: '{"title":"Dune X"}' });
|
||||||
|
const newDef = await db.k.selectFrom("custom_field_definitions").selectAll().where("user_id", "=", v).executeTakeFirstOrThrow();
|
||||||
|
expect(newDef.id).not.toBe(defId);
|
||||||
|
expect(JSON.parse(full.custom_fields)).toEqual({ [newDef.id]: "top shelf" });
|
||||||
|
|
||||||
|
const copy = await db.k.selectFrom("copies").selectAll().where("user_id", "=", v).executeTakeFirstOrThrow();
|
||||||
|
expect(copy).toMatchObject({ price_cents: 1299, currency: "EUR", acquired_precision: "month", work_id: byTitle["Dune"]!.wid });
|
||||||
|
expect(await db.k.selectFrom("listens").select("id").where("user_id", "=", v).execute()).toHaveLength(1);
|
||||||
|
const grouping = await db.k.selectFrom("lists").selectAll().where("user_id", "=", v).where("kind", "=", "grouping").executeTakeFirstOrThrow();
|
||||||
|
expect(grouping).toMatchObject({ rating: 90, review: "nice set", visibility: "public" });
|
||||||
|
const remapped = (await db.k.selectFrom("user_history").selectAll().where("user_id", "=", v).execute()).find((h) => h.after.includes("moved"))!;
|
||||||
|
expect(JSON.parse(remapped.after).workId).toBe(byTitle["Dune"]!.wid);
|
||||||
|
expect(await Bun.file(join(dir2, "attachments", atts[0]!.sha256.slice(0, 2), atts[0]!.sha256.slice(2, 4), atts[0]!.sha256)).text()).toBe("hello");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("bundle has no credential fields", async () => {
|
||||||
|
const db = getDb();
|
||||||
|
const u = await makeUser(db, "ada");
|
||||||
|
await seed(db, u);
|
||||||
|
const files = unzipSync(await exportUserBundle(db, dir, u));
|
||||||
|
expect(Object.keys(files)).toContain("manifest.json");
|
||||||
|
const keys = new Set<string>();
|
||||||
|
const walk = (v: unknown) => {
|
||||||
|
if (Array.isArray(v)) v.forEach(walk);
|
||||||
|
else if (v && typeof v === "object") for (const [k, x] of Object.entries(v)) (keys.add(k.toLowerCase().replaceAll("_", "")), walk(x));
|
||||||
|
};
|
||||||
|
for (const [n, b] of Object.entries(files)) {
|
||||||
|
if (n.endsWith(".jsonl")) strFromU8(b).split("\n").filter(Boolean).forEach((l) => walk(JSON.parse(l)));
|
||||||
|
}
|
||||||
|
expect(keys.size).toBeGreaterThan(10);
|
||||||
|
for (const bad of ["passwordhash", "tokenhash", "credentialsenc", "codehash"]) expect(keys.has(bad)).toBe(false);
|
||||||
|
expect(JSON.parse(strFromU8(files["manifest.json"]!)).format).toBe("sttracker-export/v1");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("import into non-empty account refused", async () => {
|
||||||
|
const db = getDb();
|
||||||
|
const u = await makeUser(db, "ada");
|
||||||
|
await seed(db, u);
|
||||||
|
const zip = await exportUserBundle(db, dir, u);
|
||||||
|
const err = await importUserBundle(db, dir2, u, zip).catch((e) => e);
|
||||||
|
expect(err).toBeInstanceOf(ImportError);
|
||||||
|
expect(err.code).toBe("account_not_empty");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("import of an unknown format is refused", async () => {
|
||||||
|
const db = getDb();
|
||||||
|
const v = await makeUser(db, "bob");
|
||||||
|
const zip = zipSync({ "manifest.json": strToU8('{"format":"other"}') });
|
||||||
|
const err = await importUserBundle(db, dir2, v, zip).catch((e) => e);
|
||||||
|
expect(err.code).toBe("unsupported_format");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("csv export has entries, engagements and listens", async () => {
|
||||||
|
const db = getDb();
|
||||||
|
const u = await makeUser(db, "ada");
|
||||||
|
await seed(db, u);
|
||||||
|
const csv = await exportUserCsv(db, u);
|
||||||
|
expect(Object.keys(csv).sort()).toEqual(["engagements.csv", "entries.csv", "listens.csv"]);
|
||||||
|
expect(csv["entries.csv"]).toContain("Dune");
|
||||||
|
expect(csv["entries.csv"]).not.toContain("Show,");
|
||||||
|
expect(csv["engagements.csv"]).toContain("kindle");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("a per-user move restores as alias plus tombstone and later upserts resolve to the moved entry", async () => {
|
||||||
|
const db = getDb();
|
||||||
|
const u = await makeUser(db, "ada");
|
||||||
|
const draft = await quickAdd(db, u, { title: "Dune??", mediaType: "book" });
|
||||||
|
const shared = await makeWork(db, { title: "Dune", mediaType: "book", externalIds: { isbn13: "9780441013593" } });
|
||||||
|
await moveEntryToWork(db, u, draft.id, shared, "user");
|
||||||
|
const zip = await exportUserBundle(db, dir, u);
|
||||||
|
|
||||||
|
const v = await makeUser(db, "bob");
|
||||||
|
await importUserBundle(db, dir2, v, zip);
|
||||||
|
const alias = await db.k.selectFrom("work_aliases").selectAll().where("user_id", "=", v).executeTakeFirstOrThrow();
|
||||||
|
expect(alias.to_work_id).toBe(shared);
|
||||||
|
const tomb = await db.k.selectFrom("works").selectAll().where("id", "=", alias.from_work_id).executeTakeFirstOrThrow();
|
||||||
|
expect(tomb).toMatchObject({ merged_into_id: shared, owner_user_id: v });
|
||||||
|
const moved = await upsertEntry(db, v, tomb.id, { rating: 10 }, "user");
|
||||||
|
expect(moved.workId).toBe(shared);
|
||||||
|
expect(await db.k.selectFrom("entries").select("id").where("user_id", "=", v).execute()).toHaveLength(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("an invalid attachment aborts the import with nothing written", async () => {
|
||||||
|
const db = getDb();
|
||||||
|
const u = await makeUser(db, "ada");
|
||||||
|
await seed(db, u);
|
||||||
|
const files = unzipSync(await exportUserBundle(db, dir, u));
|
||||||
|
const key = Object.keys(files).find((n) => n.startsWith("attachments/"))!;
|
||||||
|
files[key] = new TextEncoder().encode("tampered");
|
||||||
|
const v = await makeUser(db, "bob");
|
||||||
|
const err = await importUserBundle(db, dir2, v, zipSync(files)).catch((e) => e);
|
||||||
|
expect(err.code).toBe("invalid_attachment");
|
||||||
|
expect(await db.k.selectFrom("entries").select("id").where("user_id", "=", v).execute()).toHaveLength(0);
|
||||||
|
expect(await db.k.selectFrom("attachments").select("id").where("user_id", "=", v).execute()).toHaveLength(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("an out-of-range rating is a corrupt bundle", async () => {
|
||||||
|
const db = getDb();
|
||||||
|
const u = await makeUser(db, "ada");
|
||||||
|
await seed(db, u);
|
||||||
|
const files = unzipSync(await exportUserBundle(db, dir, u));
|
||||||
|
files["entries.jsonl"] = strToU8(strFromU8(files["entries.jsonl"]!).replaceAll('"rating":85', '"rating":185'));
|
||||||
|
const v = await makeUser(db, "bob");
|
||||||
|
const err = await importUserBundle(db, dir2, v, zipSync(files)).catch((e) => e);
|
||||||
|
expect(err.code).toBe("corrupt_bundle");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
export default { nope: true };
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
import { definePlugin, type SourceRecord } from "@sttracker/plugin-sdk/types";
|
||||||
|
|
||||||
|
export default definePlugin({
|
||||||
|
manifest: {
|
||||||
|
id: "echo",
|
||||||
|
name: "Echo",
|
||||||
|
version: "0.0.1",
|
||||||
|
sdkVersion: 1,
|
||||||
|
mediaTypes: ["book"],
|
||||||
|
capabilities: ["pull", "pullChanges"],
|
||||||
|
config: [
|
||||||
|
{ key: "label", label: "Label", type: "string" },
|
||||||
|
{ key: "token", label: "Token", type: "secret" },
|
||||||
|
],
|
||||||
|
rateLimit: { perMinute: 30, burst: 2 },
|
||||||
|
},
|
||||||
|
async *pull(ctx) {
|
||||||
|
const cfg = ctx.config;
|
||||||
|
if (cfg.label === "explode") {
|
||||||
|
await ctx.credentials.save({ refresh: "new-refresh" });
|
||||||
|
throw new Error("boom after save");
|
||||||
|
}
|
||||||
|
ctx.log("echo pulled");
|
||||||
|
const rec: SourceRecord = {
|
||||||
|
kind: "entry",
|
||||||
|
key: "echo-1",
|
||||||
|
raw: { label: cfg.label ?? null },
|
||||||
|
work: { title: String(cfg.label ?? "Echo"), mediaType: "book", externalIds: { isbn13: "9780000000002" } },
|
||||||
|
entry: { status: "completed" },
|
||||||
|
};
|
||||||
|
yield rec;
|
||||||
|
},
|
||||||
|
async *pullChanges(ctx) {
|
||||||
|
yield* this.pull!(ctx);
|
||||||
|
},
|
||||||
|
});
|
||||||
@@ -0,0 +1,114 @@
|
|||||||
|
import { expect, test } from "bun:test";
|
||||||
|
import { forEachDialect } from "@sttracker/db/testing";
|
||||||
|
import { defineCustomField } from "../src/custom-fields.ts";
|
||||||
|
import { NotFoundError } from "../src/errors.ts";
|
||||||
|
import { getEntry, moveEntryToWork, quickAdd, softDeleteEntry, upsertEntry } from "../src/entries.ts";
|
||||||
|
import { getHistory, recordHistory, restoreVersion } from "../src/history.ts";
|
||||||
|
import { makeUser, makeWork } from "./user-layer-fixtures.ts";
|
||||||
|
|
||||||
|
forEachDialect("history", (getDb) => {
|
||||||
|
test("recordHistory stores only the changed keys", async () => {
|
||||||
|
const db = getDb();
|
||||||
|
const userId = await makeUser(db);
|
||||||
|
const id = await recordHistory(db.k, {
|
||||||
|
userId,
|
||||||
|
entity: "entry",
|
||||||
|
entityId: "e1",
|
||||||
|
before: { status: "planned", rating: 50, tags: ["a"] },
|
||||||
|
after: { status: "completed", rating: 50, tags: ["a"] },
|
||||||
|
actor: "user",
|
||||||
|
});
|
||||||
|
expect(id).not.toBeNull();
|
||||||
|
const none = await recordHistory(db.k, {
|
||||||
|
userId,
|
||||||
|
entity: "entry",
|
||||||
|
entityId: "e1",
|
||||||
|
before: { rating: 1 },
|
||||||
|
after: { rating: 1 },
|
||||||
|
actor: "user",
|
||||||
|
});
|
||||||
|
expect(none).toBeNull();
|
||||||
|
const rows = await getHistory(db, userId, "entry", "e1");
|
||||||
|
expect(rows).toHaveLength(1);
|
||||||
|
expect(rows[0]!.before).toEqual({ status: "planned" });
|
||||||
|
expect(rows[0]!.after).toEqual({ status: "completed" });
|
||||||
|
expect(rows[0]!.actor).toBe("user");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("restoreVersion brings back previous review", async () => {
|
||||||
|
const db = getDb();
|
||||||
|
const userId = await makeUser(db);
|
||||||
|
const workId = await makeWork(db);
|
||||||
|
const e = await upsertEntry(db, userId, workId, { review: "first take" }, "user");
|
||||||
|
await upsertEntry(db, userId, workId, { review: "second take", rating: 60 }, "plugin:trakt:c1");
|
||||||
|
|
||||||
|
const history = await getHistory(db, userId, "entry", e.id);
|
||||||
|
expect(history).toHaveLength(2);
|
||||||
|
const latest = history[0]!; // newest first
|
||||||
|
expect(latest.before).toEqual({ review: "first take", rating: null });
|
||||||
|
expect(latest.actor).toBe("plugin:trakt:c1");
|
||||||
|
|
||||||
|
await restoreVersion(db, userId, latest.id);
|
||||||
|
const view = await getEntry(db, userId, e.id);
|
||||||
|
expect(view.review).toBe("first take");
|
||||||
|
expect(view.rating).toBeNull();
|
||||||
|
expect(view.fieldSources.review).toBe("user");
|
||||||
|
expect(view.fieldSources.rating).toBe("user");
|
||||||
|
expect(await getHistory(db, userId, "entry", e.id)).toHaveLength(3);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("restoreVersion undoes a soft delete", async () => {
|
||||||
|
const db = getDb();
|
||||||
|
const userId = await makeUser(db);
|
||||||
|
const e = await upsertEntry(db, userId, await makeWork(db), { status: "planned" }, "user");
|
||||||
|
await softDeleteEntry(db, userId, e.id, "user");
|
||||||
|
const [del] = await getHistory(db, userId, "entry", e.id);
|
||||||
|
expect(del!.after.deletedAt).toBeString();
|
||||||
|
await restoreVersion(db, userId, del!.id);
|
||||||
|
expect((await getEntry(db, userId, e.id)).status).toBe("planned");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("restoreVersion undoes a move back to the draft work", async () => {
|
||||||
|
const db = getDb();
|
||||||
|
const userId = await makeUser(db);
|
||||||
|
const draft = await quickAdd(db, userId, { title: "Dune??" });
|
||||||
|
const shared = await makeWork(db, { title: "Dune", externalIds: { isbn13: "1" } });
|
||||||
|
await moveEntryToWork(db, userId, draft.id, shared, "user");
|
||||||
|
const [move] = await getHistory(db, userId, "entry", draft.id);
|
||||||
|
await restoreVersion(db, userId, move!.id);
|
||||||
|
const back = await getEntry(db, userId, draft.id);
|
||||||
|
expect(back.workId).toBe(draft.workId);
|
||||||
|
expect(back.draft).toBe(true);
|
||||||
|
const work = await db.k.selectFrom("works").selectAll().where("id", "=", draft.workId).executeTakeFirstOrThrow();
|
||||||
|
expect(work.merged_into_id).toBeNull();
|
||||||
|
// undoing removes the forward alias and writes no reverse alias
|
||||||
|
expect(await db.k.selectFrom("work_aliases").selectAll().where("user_id", "=", userId).execute()).toEqual([]);
|
||||||
|
// a later sync onto the shared work lands on the shared work, not on the draft
|
||||||
|
const synced = await upsertEntry(db, userId, shared, { rating: 80 }, "plugin:hardcover:c1");
|
||||||
|
expect(synced.workId).toBe(shared);
|
||||||
|
expect(synced.id).not.toBe(draft.id);
|
||||||
|
expect((await getEntry(db, userId, draft.id)).rating).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("restoreVersion does not re-check custom fields against today's definitions", async () => {
|
||||||
|
const db = getDb();
|
||||||
|
const userId = await makeUser(db);
|
||||||
|
const def = await defineCustomField(db, userId, { name: "Mood", type: "text" });
|
||||||
|
const e = await upsertEntry(db, userId, await makeWork(db), { customFields: { [def.id]: "cosy" } }, "user");
|
||||||
|
await upsertEntry(db, userId, e.workId, { customFields: {} }, "user");
|
||||||
|
await db.k.deleteFrom("custom_field_definitions").where("id", "=", def.id).execute();
|
||||||
|
const [h] = await getHistory(db, userId, "entry", e.id);
|
||||||
|
await restoreVersion(db, userId, h!.id);
|
||||||
|
expect((await getEntry(db, userId, e.id)).customFields).toEqual({ [def.id]: "cosy" });
|
||||||
|
});
|
||||||
|
|
||||||
|
test("another user's history is invisible", async () => {
|
||||||
|
const db = getDb();
|
||||||
|
const ada = await makeUser(db);
|
||||||
|
const bob = await makeUser(db, "bob");
|
||||||
|
const e = await upsertEntry(db, ada, await makeWork(db), { review: "x" }, "user");
|
||||||
|
expect(await getHistory(db, bob, "entry", e.id)).toEqual([]);
|
||||||
|
const [h] = await getHistory(db, ada, "entry", e.id);
|
||||||
|
await expect(restoreVersion(db, bob, h!.id)).rejects.toThrow(NotFoundError);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,195 @@
|
|||||||
|
import { expect, spyOn, test } from "bun:test";
|
||||||
|
import { forEachDialect } from "@sttracker/db/testing";
|
||||||
|
import { DropJobError, enqueue, NonRetryableError, startScheduler, startWorker } from "../src/jobs.ts";
|
||||||
|
import { makeConnection, makeUser } from "./sync/helpers.ts";
|
||||||
|
|
||||||
|
const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms));
|
||||||
|
|
||||||
|
forEachDialect("jobs", (getDb) => {
|
||||||
|
test("enqueue with same lockKey returns null", async () => {
|
||||||
|
const db = getDb();
|
||||||
|
expect(await enqueue(db, "sync", {}, { lockKey: "sync:a" })).toBeString();
|
||||||
|
expect(await enqueue(db, "sync", {}, { lockKey: "sync:a" })).toBeNull();
|
||||||
|
expect(await enqueue(db, "sync", {}, { lockKey: "sync:b" })).toBeString();
|
||||||
|
expect(await enqueue(db, "x", {})).toBeString();
|
||||||
|
expect(await enqueue(db, "x", {})).toBeString();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("concurrent enqueue with one lockKey admits exactly one", async () => {
|
||||||
|
const db = getDb();
|
||||||
|
const ids = await Promise.all(Array.from({ length: 6 }, () => enqueue(db, "sync", {}, { lockKey: "k" })));
|
||||||
|
expect(ids.filter((i) => i !== null)).toHaveLength(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("worker runs a job and releases the lock", async () => {
|
||||||
|
const db = getDb();
|
||||||
|
const seen: unknown[] = [];
|
||||||
|
const id = await enqueue(db, "t", { n: 1 }, { lockKey: "l" });
|
||||||
|
const w = startWorker(db, { t: async (p) => void seen.push(p) }, { pollMs: 10 });
|
||||||
|
await sleep(150);
|
||||||
|
await w.stop();
|
||||||
|
expect(seen).toEqual([{ n: 1 }]);
|
||||||
|
expect((await db.k.selectFrom("jobs").selectAll().where("id", "=", id!).executeTakeFirstOrThrow()).status).toBe("done");
|
||||||
|
expect(await enqueue(db, "t", {}, { lockKey: "l" })).toBeString();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("a job is claimed by only one of two workers", async () => {
|
||||||
|
const db = getDb();
|
||||||
|
let runs = 0;
|
||||||
|
const h = { t: async () => { runs++; await sleep(30); } };
|
||||||
|
await enqueue(db, "t", {});
|
||||||
|
const a = startWorker(db, h, { pollMs: 5 });
|
||||||
|
const b = startWorker(db, h, { pollMs: 5 });
|
||||||
|
await sleep(200);
|
||||||
|
await Promise.all([a.stop(), b.stop()]);
|
||||||
|
expect(runs).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("worker retries then fails", async () => {
|
||||||
|
const db = getDb();
|
||||||
|
let calls = 0;
|
||||||
|
const id = (await enqueue(db, "bad", {}))!;
|
||||||
|
const w = startWorker(db, { bad: async () => { calls++; throw new Error("nope"); } }, { pollMs: 10 });
|
||||||
|
const row = () => db.k.selectFrom("jobs").selectAll().where("id", "=", id).executeTakeFirstOrThrow();
|
||||||
|
for (let attempt = 1; attempt <= 5; attempt++) {
|
||||||
|
for (let i = 0; i < 100 && (await row()).attempts < attempt; i++) await sleep(10);
|
||||||
|
const r = await row();
|
||||||
|
expect(r.attempts).toBe(attempt);
|
||||||
|
expect(r.last_error).toBe("nope");
|
||||||
|
if (attempt < 5) {
|
||||||
|
expect(r.status).toBe("queued");
|
||||||
|
const delay = Date.parse(r.run_at) - Date.now();
|
||||||
|
expect(delay).toBeGreaterThan(2 ** attempt * 60_000 - 5_000);
|
||||||
|
expect(delay).toBeLessThanOrEqual(2 ** attempt * 60_000);
|
||||||
|
await db.k.updateTable("jobs").set({ run_at: new Date(Date.now() - 1000).toISOString() }).where("id", "=", id).execute();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
await w.stop();
|
||||||
|
expect((await row()).status).toBe("failed");
|
||||||
|
expect(calls).toBe(5);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("scheduler enqueues due connections only", async () => {
|
||||||
|
const db = getDb();
|
||||||
|
const u = await makeUser(db);
|
||||||
|
const due = await makeConnection(db, u, "p");
|
||||||
|
const fresh = await makeConnection(db, u, "p");
|
||||||
|
const manual = await makeConnection(db, u, "p");
|
||||||
|
const errored = await makeConnection(db, u, "p");
|
||||||
|
const paused = await makeConnection(db, u, "p");
|
||||||
|
const old = new Date(Date.now() - 120 * 60_000).toISOString();
|
||||||
|
const recent = new Date(Date.now() - 5 * 60_000).toISOString();
|
||||||
|
const set = (id: string, v: object) => db.k.updateTable("source_connections").set(v).where("id", "=", id).execute();
|
||||||
|
await set(due, { schedule_minutes: 60, last_run_at: old });
|
||||||
|
await set(fresh, { schedule_minutes: 60, last_run_at: recent });
|
||||||
|
await set(errored, { schedule_minutes: 60, last_run_at: old, status: "error" });
|
||||||
|
await set(paused, { schedule_minutes: 60, last_run_at: old, status: "paused" });
|
||||||
|
void manual;
|
||||||
|
const s = startScheduler(db, { tickMs: 3_600_000 });
|
||||||
|
await s.tickNow();
|
||||||
|
await s.tickNow();
|
||||||
|
s.stop();
|
||||||
|
const jobs = await db.k.selectFrom("jobs").selectAll().execute();
|
||||||
|
expect(jobs.map((j) => j.lock_key).sort()).toEqual([`sync:${due}`, `sync:${errored}`].sort());
|
||||||
|
expect(JSON.parse(jobs.find((j) => j.lock_key === `sync:${due}`)!.payload)).toEqual({ connectionId: due });
|
||||||
|
});
|
||||||
|
|
||||||
|
const row = (db: ReturnType<typeof getDb>, id: string) => db.k.selectFrom("jobs").selectAll().where("id", "=", id).executeTakeFirstOrThrow();
|
||||||
|
async function until(fn: () => Promise<boolean>) {
|
||||||
|
for (let i = 0; i < 200 && !(await fn()); i++) await sleep(10);
|
||||||
|
}
|
||||||
|
|
||||||
|
test("NonRetryableError fails at once; DropJobError completes with last_error", async () => {
|
||||||
|
const db = getDb();
|
||||||
|
const a = (await enqueue(db, "a", {}))!;
|
||||||
|
const b = (await enqueue(db, "b", {}))!;
|
||||||
|
const w = startWorker(db, { a: async () => { throw new NonRetryableError("fatal"); }, b: async () => { throw new DropJobError("gone"); } }, { pollMs: 10 });
|
||||||
|
await until(async () => (await row(db, a)).status !== "queued" && (await row(db, b)).status !== "queued");
|
||||||
|
await w.stop();
|
||||||
|
expect(await row(db, a)).toMatchObject({ status: "failed", attempts: 1, last_error: "fatal" });
|
||||||
|
expect(await row(db, b)).toMatchObject({ status: "done", last_error: "gone" });
|
||||||
|
});
|
||||||
|
|
||||||
|
test("a stale running job is re-queued and then runs; a fresh one is left", async () => {
|
||||||
|
const db = getDb();
|
||||||
|
const stale = (await enqueue(db, "t", {}, { lockKey: "s" }))!;
|
||||||
|
const fresh = (await enqueue(db, "never", {}))!;
|
||||||
|
await db.k.updateTable("jobs").set({ status: "running", locked_at: new Date(Date.now() - 2 * 3600_000).toISOString(), attempts: 2 }).where("id", "=", stale).execute();
|
||||||
|
await db.k.updateTable("jobs").set({ status: "running", locked_at: nowStr() }).where("id", "=", fresh).execute();
|
||||||
|
let ran = 0;
|
||||||
|
const w = startWorker(db, { t: async () => void ran++ }, { pollMs: 10 });
|
||||||
|
await until(async () => (await row(db, stale)).status === "done");
|
||||||
|
await w.stop();
|
||||||
|
expect(ran).toBe(1);
|
||||||
|
expect((await row(db, stale)).attempts).toBe(2);
|
||||||
|
expect((await row(db, fresh)).status).toBe("running");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("failing bookkeeping is logged and stop() does not reject", async () => {
|
||||||
|
const db = getDb();
|
||||||
|
const err = spyOn(console, "error").mockImplementation(() => {});
|
||||||
|
await enqueue(db, "t", {});
|
||||||
|
let updates = 0;
|
||||||
|
const k = new Proxy(db.k, {
|
||||||
|
get(t, p) {
|
||||||
|
const v = Reflect.get(t, p, t);
|
||||||
|
if (p === "updateTable") return (...a: unknown[]) => { if (++updates > 1) throw new Error("db down"); return (v as Function).apply(t, a); };
|
||||||
|
return typeof v === "function" ? v.bind(t) : v;
|
||||||
|
},
|
||||||
|
});
|
||||||
|
let called = false;
|
||||||
|
const w = startWorker({ ...db, k } as typeof db, { t: async () => void (called = true) }, { pollMs: 10 });
|
||||||
|
await until(async () => called && err.mock.calls.length > 0);
|
||||||
|
await expect(w.stop()).resolves.toBeUndefined();
|
||||||
|
expect(err.mock.calls.some((c) => c[0] === "[jobs]")).toBe(true);
|
||||||
|
err.mockRestore();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("a job running past staleMs is not reaped by its own worker and runs once", async () => {
|
||||||
|
const db = getDb();
|
||||||
|
const id = (await enqueue(db, "t", {}, { lockKey: "slow" }))!;
|
||||||
|
let runs = 0;
|
||||||
|
const w = startWorker(db, { t: async () => { runs++; await sleep(300); } }, { pollMs: 10, staleMs: 50, heartbeatMs: 20 });
|
||||||
|
await until(async () => (await row(db, id)).status === "done");
|
||||||
|
await w.stop();
|
||||||
|
expect(runs).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("heartbeat refreshes locked_at", async () => {
|
||||||
|
const db = getDb();
|
||||||
|
const id = (await enqueue(db, "t", {}))!;
|
||||||
|
let release!: () => void;
|
||||||
|
const gate = new Promise<void>((r) => (release = r));
|
||||||
|
const w = startWorker(db, { t: () => gate }, { pollMs: 10, heartbeatMs: 20 });
|
||||||
|
await until(async () => (await row(db, id)).status === "running");
|
||||||
|
const first = (await row(db, id)).locked_at!;
|
||||||
|
await until(async () => (await row(db, id)).locked_at !== first);
|
||||||
|
expect((await row(db, id)).locked_at).not.toBe(first);
|
||||||
|
release();
|
||||||
|
await w.stop();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("stop() re-queues an aborted job without burning an attempt or backoff", async () => {
|
||||||
|
const db = getDb();
|
||||||
|
const id = (await enqueue(db, "t", {}))!;
|
||||||
|
const before = (await row(db, id)).run_at;
|
||||||
|
let started = false;
|
||||||
|
const w = startWorker(db, { t: (_p, { signal }) => new Promise<void>((_, rej) => { started = true; signal.addEventListener("abort", () => rej(new Error("aborted"))); }) }, { pollMs: 10 });
|
||||||
|
await until(async () => started);
|
||||||
|
await w.stop();
|
||||||
|
expect(await row(db, id)).toMatchObject({ status: "queued", attempts: 0, run_at: before, locked_at: null });
|
||||||
|
});
|
||||||
|
|
||||||
|
test("stop() aborts the signal handed to handlers", async () => {
|
||||||
|
const db = getDb();
|
||||||
|
await enqueue(db, "t", {});
|
||||||
|
let aborted = false;
|
||||||
|
let started = false;
|
||||||
|
const w = startWorker(db, { t: (_p, { signal }) => new Promise<void>((res) => { started = true; signal.addEventListener("abort", () => { aborted = true; res(); }); }) }, { pollMs: 10 });
|
||||||
|
await until(async () => started);
|
||||||
|
await w.stop();
|
||||||
|
expect(aborted).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
const nowStr = () => new Date().toISOString();
|
||||||
@@ -0,0 +1,104 @@
|
|||||||
|
import { expect, test } from "bun:test";
|
||||||
|
import { forEachDialect } from "@sttracker/db/testing";
|
||||||
|
import { NotFoundError, ValidationError } from "../src/errors.ts";
|
||||||
|
import { getHistory, restoreVersion } from "../src/history.ts";
|
||||||
|
import {
|
||||||
|
addListItem,
|
||||||
|
createList,
|
||||||
|
getList,
|
||||||
|
listLists,
|
||||||
|
moveListItem,
|
||||||
|
removeListItem,
|
||||||
|
softDeleteList,
|
||||||
|
updateList,
|
||||||
|
} from "../src/lists.ts";
|
||||||
|
import { makeUser, makeWork } from "./user-layer-fixtures.ts";
|
||||||
|
|
||||||
|
forEachDialect("lists", (getDb) => {
|
||||||
|
test("grouping list can be rated", async () => {
|
||||||
|
const db = getDb();
|
||||||
|
const userId = await makeUser(db);
|
||||||
|
const g = await createList(db, userId, { name: "Silent Hill, everything", kind: "grouping", rating: 90, review: "Dread." });
|
||||||
|
expect(g.kind).toBe("grouping");
|
||||||
|
expect(g.rating).toBe(90);
|
||||||
|
expect(g.review).toBe("Dread.");
|
||||||
|
expect(g.visibility).toBe("private");
|
||||||
|
const up = await updateList(db, userId, g.id, { rating: 95 });
|
||||||
|
expect(up.rating).toBe(95);
|
||||||
|
|
||||||
|
await expect(createList(db, userId, { name: "Plain", kind: "list", rating: 50 })).rejects.toThrow(ValidationError);
|
||||||
|
await expect(createList(db, userId, { name: "Bad", kind: "grouping", rating: 101 })).rejects.toThrow(
|
||||||
|
ValidationError,
|
||||||
|
);
|
||||||
|
await expect(updateList(db, userId, g.id, { kind: "thread" })).rejects.toThrow(ValidationError);
|
||||||
|
await expect(createList(db, userId, { name: " " })).rejects.toThrow(ValidationError);
|
||||||
|
await expect(createList(db, userId, { name: "x", kind: "box" as never })).rejects.toThrow(ValidationError);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("items keep order through add, move and remove", async () => {
|
||||||
|
const db = getDb();
|
||||||
|
const userId = await makeUser(db);
|
||||||
|
const l = await createList(db, userId, { name: "Cosmic dread", kind: "thread" });
|
||||||
|
const [a, b, c] = [await makeWork(db, { title: "A" }), await makeWork(db, { title: "B" }), await makeWork(db, { title: "C" })];
|
||||||
|
const ia = await addListItem(db, userId, l.id, { workId: a, note: "start here" });
|
||||||
|
const ib = await addListItem(db, userId, l.id, { workId: b });
|
||||||
|
const ic = await addListItem(db, userId, l.id, { workId: c });
|
||||||
|
const titles = async () => (await getList(db, userId, l.id)).items.map((i) => i.work.title);
|
||||||
|
expect(await titles()).toEqual(["A", "B", "C"]);
|
||||||
|
expect((await getList(db, userId, l.id)).items[0]!.note).toBe("start here");
|
||||||
|
|
||||||
|
await moveListItem(db, userId, ic.id, 0);
|
||||||
|
expect(await titles()).toEqual(["C", "A", "B"]);
|
||||||
|
await moveListItem(db, userId, ic.id, 99);
|
||||||
|
expect(await titles()).toEqual(["A", "B", "C"]);
|
||||||
|
await removeListItem(db, userId, ib.id);
|
||||||
|
expect(await titles()).toEqual(["A", "C"]);
|
||||||
|
expect((await getList(db, userId, l.id)).items.map((i) => i.position)).toEqual([0, 1]);
|
||||||
|
|
||||||
|
const inserted = await addListItem(db, userId, l.id, { workId: b, position: 1 });
|
||||||
|
expect(await titles()).toEqual(["A", "B", "C"]);
|
||||||
|
expect(inserted.position).toBe(1);
|
||||||
|
expect(ia.position).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("removing an item is recorded and restorable", async () => {
|
||||||
|
const db = getDb();
|
||||||
|
const userId = await makeUser(db);
|
||||||
|
const l = await createList(db, userId, { name: "Reading order" });
|
||||||
|
await addListItem(db, userId, l.id, { workId: await makeWork(db, { title: "A" }) });
|
||||||
|
const ib = await addListItem(db, userId, l.id, { workId: await makeWork(db, { title: "B" }) });
|
||||||
|
await removeListItem(db, userId, ib.id);
|
||||||
|
const [h] = await getHistory(db, userId, "list", l.id);
|
||||||
|
await restoreVersion(db, userId, h!.id);
|
||||||
|
expect((await getList(db, userId, l.id)).items.map((i) => i.work.title)).toEqual(["A", "B"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("listLists and soft delete", async () => {
|
||||||
|
const db = getDb();
|
||||||
|
const userId = await makeUser(db);
|
||||||
|
const l1 = await createList(db, userId, { name: "One" });
|
||||||
|
const l2 = await createList(db, userId, { name: "Two", kind: "grouping" });
|
||||||
|
expect((await listLists(db, userId)).map((l) => l.id).sort()).toEqual([l1.id, l2.id].sort());
|
||||||
|
expect((await listLists(db, userId, { kind: "grouping" })).map((l) => l.id)).toEqual([l2.id]);
|
||||||
|
await softDeleteList(db, userId, l1.id);
|
||||||
|
expect((await listLists(db, userId)).map((l) => l.id)).toEqual([l2.id]);
|
||||||
|
await expect(getList(db, userId, l1.id)).rejects.toThrow(NotFoundError);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("another user's lists and private works are not found", async () => {
|
||||||
|
const db = getDb();
|
||||||
|
const ada = await makeUser(db);
|
||||||
|
const bob = await makeUser(db, "bob");
|
||||||
|
const l = await createList(db, ada, { name: "Mine" });
|
||||||
|
const item = await addListItem(db, ada, l.id, { workId: await makeWork(db) });
|
||||||
|
await expect(getList(db, bob, l.id)).rejects.toThrow(NotFoundError);
|
||||||
|
await expect(updateList(db, bob, l.id, { name: "x" })).rejects.toThrow(NotFoundError);
|
||||||
|
await expect(addListItem(db, bob, l.id, { workId: await makeWork(db) })).rejects.toThrow(NotFoundError);
|
||||||
|
await expect(moveListItem(db, bob, item.id, 0)).rejects.toThrow(NotFoundError);
|
||||||
|
await expect(removeListItem(db, bob, item.id)).rejects.toThrow(NotFoundError);
|
||||||
|
expect(await listLists(db, bob)).toEqual([]);
|
||||||
|
const adaPrivate = await makeWork(db, { ownerUserId: ada });
|
||||||
|
const bobList = await createList(db, bob, { name: "Bob's" });
|
||||||
|
await expect(addListItem(db, bob, bobList.id, { workId: adaPrivate })).rejects.toThrow(NotFoundError);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,163 @@
|
|||||||
|
import { expect, test } from "bun:test";
|
||||||
|
import type { Db } from "@sttracker/db/db";
|
||||||
|
import { newId, nowIso } from "@sttracker/db/ids";
|
||||||
|
import { forEachDialect } from "@sttracker/db/testing";
|
||||||
|
import { fuzzyScore, matchWork } from "../src/matching.ts";
|
||||||
|
import { adminMergeWorks, createWork, getWork } from "../src/works.ts";
|
||||||
|
|
||||||
|
async function addUser(db: Db): Promise<string> {
|
||||||
|
const id = newId();
|
||||||
|
const now = nowIso();
|
||||||
|
await db.k
|
||||||
|
.insertInto("users")
|
||||||
|
.values({ id, username: `u${id}`, display_name: "U", password_hash: "x", role: "member", preferences: "{}", created_at: now, updated_at: now })
|
||||||
|
.execute();
|
||||||
|
return id;
|
||||||
|
}
|
||||||
|
|
||||||
|
test("fuzzyScore", () => {
|
||||||
|
expect(fuzzyScore({ title: "The Expanse: Leviathan Wakes" }, { title: "Leviathan Wakes" })).toBeGreaterThan(0.6);
|
||||||
|
expect(fuzzyScore({ title: "Dune" }, { title: "Dune Messiah" })).toBeLessThan(0.6);
|
||||||
|
expect(fuzzyScore({ title: "Pokémon" }, { title: "Pokemon" })).toBe(1);
|
||||||
|
expect(
|
||||||
|
fuzzyScore({ title: "Pokémon", year: 1996, creators: [{ name: "Satoshi" }] }, { title: "pokemon!", year: 1996, creators: [{ name: "satoshi" }] }),
|
||||||
|
).toBe(1);
|
||||||
|
// year off by one counts half, creator mismatch counts zero
|
||||||
|
expect(fuzzyScore({ title: "A B", year: 2000, creators: [{ name: "x" }] }, { title: "A B", year: 2001, creators: [{ name: "y" }] })).toBeCloseTo(0.8, 5);
|
||||||
|
});
|
||||||
|
|
||||||
|
forEachDialect("matching", (getDb) => {
|
||||||
|
test("match by isbn13 links existing", async () => {
|
||||||
|
const db = getDb();
|
||||||
|
const u = await addUser(db);
|
||||||
|
const id = await createWork(db, { hint: { title: "Dune", mediaType: "book", externalIds: { isbn13: "9780441013593" } } }, "t");
|
||||||
|
const r = await matchWork(db, u, { title: "Dune (Ace)", mediaType: "book", year: 1990, externalIds: { isbn13: "9780441013593", asin: "B1" } }, "plug");
|
||||||
|
expect(r).toEqual({ workId: id, created: false, draft: false });
|
||||||
|
expect((await getWork(db, id))?.year).toBe(1990);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("unseen external id creates shared work", async () => {
|
||||||
|
const db = getDb();
|
||||||
|
const u = await addUser(db);
|
||||||
|
const r = await matchWork(db, u, { title: "Heat", mediaType: "movie", externalIds: { tmdb_movie: "949" } }, "p");
|
||||||
|
expect(r.created).toBe(true);
|
||||||
|
expect(r.draft).toBe(false);
|
||||||
|
expect((await getWork(db, r.workId))?.owner_user_id).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
const chain = () => ({
|
||||||
|
title: "Pilot",
|
||||||
|
mediaType: "episode" as const,
|
||||||
|
externalIds: {},
|
||||||
|
position: { episode: 1, season: 1 },
|
||||||
|
parent: {
|
||||||
|
title: "Season 1",
|
||||||
|
mediaType: "season" as const,
|
||||||
|
externalIds: {},
|
||||||
|
position: { season: 1 },
|
||||||
|
parent: { title: "Lost", mediaType: "show" as const, externalIds: { tmdb_show: "4607" } },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
test("episode hint creates show→season→episode chain once", async () => {
|
||||||
|
const db = getDb();
|
||||||
|
const u = await addUser(db);
|
||||||
|
const r1 = await matchWork(db, u, chain(), "p");
|
||||||
|
const r2 = await matchWork(db, u, chain(), "p");
|
||||||
|
expect(r1.created).toBe(true);
|
||||||
|
expect(r2).toEqual({ workId: r1.workId, created: false, draft: false });
|
||||||
|
expect(await db.k.selectFrom("works").select("id").execute()).toHaveLength(3);
|
||||||
|
const ep = await getWork(db, r1.workId);
|
||||||
|
expect(ep?.position).toEqual({ season: 1, episode: 1 });
|
||||||
|
expect(ep?.parent_id).not.toBeNull();
|
||||||
|
const season = await getWork(db, ep!.parent_id!);
|
||||||
|
expect(season?.media_type).toBe("season");
|
||||||
|
expect((await getWork(db, season!.parent_id!))?.media_type).toBe("show");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("position key order does not matter", async () => {
|
||||||
|
const db = getDb();
|
||||||
|
const u = await addUser(db);
|
||||||
|
const a = await matchWork(db, u, chain(), "p");
|
||||||
|
const h = chain();
|
||||||
|
h.position = { season: 1, episode: 1 };
|
||||||
|
expect((await matchWork(db, u, h, "p")).workId).toBe(a.workId);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("hint without ids creates private draft with suggestions", async () => {
|
||||||
|
const db = getDb();
|
||||||
|
const u = await addUser(db);
|
||||||
|
const shared = await createWork(db, { hint: { title: "Leviathan Wakes", mediaType: "book", externalIds: { isbn13: "1" } } }, "t");
|
||||||
|
await createWork(db, { hint: { title: "Leviathan Wakes", mediaType: "game", externalIds: { igdb: "1" } } }, "t");
|
||||||
|
await createWork(db, { hint: { title: "Unrelated", mediaType: "book", externalIds: { isbn13: "2" } } }, "t");
|
||||||
|
const r = await matchWork(db, u, { title: "The Expanse: Leviathan Wakes", mediaType: "book", externalIds: {} }, "p");
|
||||||
|
expect(r.created).toBe(true);
|
||||||
|
expect(r.draft).toBe(true);
|
||||||
|
expect((await getWork(db, r.workId))?.owner_user_id).toBe(u);
|
||||||
|
const sug = await db.k.selectFrom("match_suggestions").selectAll().execute();
|
||||||
|
expect(sug).toHaveLength(1);
|
||||||
|
expect(sug[0]).toMatchObject({ user_id: u, work_id: r.workId, candidate_work_id: shared, status: "pending" });
|
||||||
|
expect(sug[0]!.score).toBeGreaterThan(0.6);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("concurrent matches of the same new external id create one work", async () => {
|
||||||
|
const db = getDb();
|
||||||
|
const u = await addUser(db);
|
||||||
|
const hint = () => ({ title: "Heat", mediaType: "movie" as const, externalIds: { tmdb_movie: "949" } });
|
||||||
|
const rs = await Promise.all([matchWork(db, u, hint(), "p"), matchWork(db, u, hint(), "p"), matchWork(db, u, hint(), "p")]);
|
||||||
|
expect(new Set(rs.map((r) => r.workId)).size).toBe(1);
|
||||||
|
expect(await db.k.selectFrom("works").select("id").execute()).toHaveLength(1);
|
||||||
|
expect(await db.k.selectFrom("external_ids").select("id").execute()).toHaveLength(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("concurrent matches of the same new episode chain create it once", async () => {
|
||||||
|
const db = getDb();
|
||||||
|
const u = await addUser(db);
|
||||||
|
const rs = await Promise.all([matchWork(db, u, chain(), "p"), matchWork(db, u, chain(), "p"), matchWork(db, u, chain(), "p")]);
|
||||||
|
expect(new Set(rs.map((r) => r.workId)).size).toBe(1);
|
||||||
|
expect(await db.k.selectFrom("works").select("id").execute()).toHaveLength(3);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("concurrent matches of new episodes under an existing show create one season", async () => {
|
||||||
|
const db = getDb();
|
||||||
|
const u = await addUser(db);
|
||||||
|
await createWork(db, { hint: { title: "Lost", mediaType: "show", externalIds: { tmdb_show: "4607" } } }, "t");
|
||||||
|
const rs = await Promise.all([matchWork(db, u, chain(), "p"), matchWork(db, u, chain(), "p")]);
|
||||||
|
expect(rs[0]!.workId).toBe(rs[1]!.workId);
|
||||||
|
expect(await db.k.selectFrom("works").select("id").execute()).toHaveLength(3);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("concurrent updates adding the same external id do not fail", async () => {
|
||||||
|
const db = getDb();
|
||||||
|
const u = await addUser(db);
|
||||||
|
const id = await createWork(db, { hint: { title: "Dune", mediaType: "book", externalIds: { isbn13: "1" } } }, "t");
|
||||||
|
const hint = () => ({ title: "Dune", mediaType: "book" as const, externalIds: { isbn13: "1", asin: "B1" } });
|
||||||
|
const rs = await Promise.all([matchWork(db, u, hint(), "p"), matchWork(db, u, hint(), "p"), matchWork(db, u, hint(), "p")]);
|
||||||
|
expect(rs.map((r) => r.workId)).toEqual([id, id, id]);
|
||||||
|
expect(await db.k.selectFrom("external_ids").select("id").where("namespace", "=", "asin").execute()).toHaveLength(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("draft reflects the aliased work, not the matched one", async () => {
|
||||||
|
const db = getDb();
|
||||||
|
const u = await addUser(db);
|
||||||
|
const shared = await createWork(db, { hint: { title: "Dune", mediaType: "book", externalIds: { isbn13: "1" } } }, "t");
|
||||||
|
const priv = await createWork(db, { hint: { title: "My Dune", mediaType: "book", externalIds: {} }, ownerUserId: u }, "t");
|
||||||
|
await db.k.insertInto("work_aliases").values({ id: newId(), user_id: u, from_work_id: shared, to_work_id: priv }).execute();
|
||||||
|
expect(await matchWork(db, u, { title: "Dune", mediaType: "book", externalIds: { isbn13: "1" } }, "p")).toEqual({ workId: priv, created: false, draft: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
test("position lookup follows a merged tombstone instead of colliding with it", async () => {
|
||||||
|
const db = getDb();
|
||||||
|
const u = await addUser(db);
|
||||||
|
await db.k.updateTable("users").set({ role: "admin" }).where("id", "=", u).execute();
|
||||||
|
const e1 = await matchWork(db, u, chain(), "p");
|
||||||
|
const h2 = chain();
|
||||||
|
h2.title = "Episode 2";
|
||||||
|
h2.position = { season: 1, episode: 2 };
|
||||||
|
const e2 = await matchWork(db, u, h2, "p");
|
||||||
|
await adminMergeWorks(db, u, e1.workId, e2.workId);
|
||||||
|
const before = (await db.k.selectFrom("works").select("id").execute()).length;
|
||||||
|
expect(await matchWork(db, u, chain(), "p")).toEqual({ workId: e2.workId, created: false, draft: false });
|
||||||
|
expect(await db.k.selectFrom("works").select("id").execute()).toHaveLength(before);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
import { expect, spyOn, test } from "bun:test";
|
||||||
|
import { mkdtemp, mkdir, rm, writeFile } from "node:fs/promises";
|
||||||
|
import { tmpdir } from "node:os";
|
||||||
|
import { join } from "node:path";
|
||||||
|
import { loadPlugins } from "../src/registry.ts";
|
||||||
|
|
||||||
|
const dir = join(import.meta.dir, "fixtures/plugins");
|
||||||
|
|
||||||
|
test("registry loads echo plugin and skips broken one", async () => {
|
||||||
|
const warn = spyOn(console, "warn").mockImplementation(() => {});
|
||||||
|
const reg = await loadPlugins([dir, join(dir, "does-not-exist")]);
|
||||||
|
expect(reg.list().map((m) => m.id)).toEqual(["echo"]);
|
||||||
|
expect(reg.get("echo")?.manifest.name).toBe("Echo");
|
||||||
|
expect(reg.get("broken")).toBeUndefined();
|
||||||
|
expect(warn.mock.calls.some((c) => String(c[0]).includes("broken"))).toBe(true);
|
||||||
|
await reg.reload();
|
||||||
|
expect(reg.list()).toHaveLength(1);
|
||||||
|
warn.mockRestore();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("duplicate ids across dirs keep the first", async () => {
|
||||||
|
const warn = spyOn(console, "warn").mockImplementation(() => {});
|
||||||
|
const reg = await loadPlugins([dir, dir]);
|
||||||
|
expect(reg.list()).toHaveLength(1);
|
||||||
|
warn.mockRestore();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("reload picks up an edited entry file", async () => {
|
||||||
|
const root = await mkdtemp(join(tmpdir(), "sttracker-plugins-"));
|
||||||
|
try {
|
||||||
|
await mkdir(join(root, "hot"));
|
||||||
|
const write = (v: string) =>
|
||||||
|
writeFile(join(root, "hot", "index.ts"), `export default { manifest: { id: "hot", name: "Hot", version: "${v}", sdkVersion: 1, mediaTypes: [], capabilities: [], config: [] } };\n`);
|
||||||
|
await write("1.0.0");
|
||||||
|
const reg = await loadPlugins([root]);
|
||||||
|
expect(reg.get("hot")?.manifest.version).toBe("1.0.0");
|
||||||
|
await new Promise((r) => setTimeout(r, 5));
|
||||||
|
await write("2.0.0");
|
||||||
|
await reg.reload();
|
||||||
|
expect(reg.get("hot")?.manifest.version).toBe("2.0.0");
|
||||||
|
} finally {
|
||||||
|
await rm(root, { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
});
|
||||||
@@ -0,0 +1,70 @@
|
|||||||
|
import { expect, test } from "bun:test";
|
||||||
|
import type { Db } from "@sttracker/db/db";
|
||||||
|
import { newId, nowIso } from "@sttracker/db/ids";
|
||||||
|
import { forEachDialect } from "@sttracker/db/testing";
|
||||||
|
import { acceptSuggestion, getEntry, quickAdd, rejectSuggestion } from "../src/entries.ts";
|
||||||
|
import { createCopy, listCopies } from "../src/copies.ts";
|
||||||
|
import { NotFoundError, ValidationError } from "../src/errors.ts";
|
||||||
|
import { getHistory } from "../src/history.ts";
|
||||||
|
import { addListItem, createList, getList } from "../src/lists.ts";
|
||||||
|
import { makeUser, makeWork } from "./user-layer-fixtures.ts";
|
||||||
|
|
||||||
|
async function suggest(db: Db, userId: string, workId: string, candidateWorkId: string): Promise<string> {
|
||||||
|
const id = newId();
|
||||||
|
await db.k
|
||||||
|
.insertInto("match_suggestions")
|
||||||
|
.values({ id, user_id: userId, work_id: workId, candidate_work_id: candidateWorkId, score: 0.9, status: "pending", created_at: nowIso() })
|
||||||
|
.execute();
|
||||||
|
return id;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function statusOf(db: Db, id: string): Promise<string> {
|
||||||
|
return (await db.k.selectFrom("match_suggestions").select("status").where("id", "=", id).executeTakeFirstOrThrow()).status;
|
||||||
|
}
|
||||||
|
|
||||||
|
forEachDialect("suggestions", (getDb) => {
|
||||||
|
test("acceptSuggestion moves the entry and rejects the other pending suggestions", async () => {
|
||||||
|
const db = getDb();
|
||||||
|
const userId = await makeUser(db);
|
||||||
|
const draft = await quickAdd(db, userId, { title: "Dune??", mediaType: "book" });
|
||||||
|
const good = await makeWork(db, { title: "Dune", externalIds: { isbn13: "1" } });
|
||||||
|
const other = await makeWork(db, { title: "Dune Messiah" });
|
||||||
|
const s1 = await suggest(db, userId, draft.workId, good);
|
||||||
|
const s2 = await suggest(db, userId, draft.workId, other);
|
||||||
|
await acceptSuggestion(db, userId, s1);
|
||||||
|
expect(await statusOf(db, s1)).toBe("accepted");
|
||||||
|
expect(await statusOf(db, s2)).toBe("rejected");
|
||||||
|
const view = await getEntry(db, userId, draft.id);
|
||||||
|
expect(view.workId).toBe(good);
|
||||||
|
expect(view.draft).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("rejectSuggestion; other users get NotFoundError", async () => {
|
||||||
|
const db = getDb();
|
||||||
|
const ada = await makeUser(db);
|
||||||
|
const bob = await makeUser(db, "bob");
|
||||||
|
const s = await suggest(db, ada, await makeWork(db), await makeWork(db));
|
||||||
|
await expect(acceptSuggestion(db, bob, s)).rejects.toThrow(NotFoundError);
|
||||||
|
await expect(rejectSuggestion(db, bob, s)).rejects.toThrow(NotFoundError);
|
||||||
|
expect(await statusOf(db, s)).toBe("pending");
|
||||||
|
await rejectSuggestion(db, ada, s);
|
||||||
|
expect(await statusOf(db, s)).toBe("rejected");
|
||||||
|
await expect(rejectSuggestion(db, ada, s)).rejects.toThrow(ValidationError);
|
||||||
|
await expect(acceptSuggestion(db, ada, s)).rejects.toThrow(ValidationError);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("accepting moves this user's copies and list items to the target work", async () => {
|
||||||
|
const db = getDb();
|
||||||
|
const userId = await makeUser(db);
|
||||||
|
const draft = await quickAdd(db, userId, { title: "Dune??", mediaType: "book" });
|
||||||
|
const target = await makeWork(db, { title: "Dune", externalIds: { isbn13: "1" } });
|
||||||
|
const copy = await createCopy(db, userId, { workId: draft.workId, format: "paperback" });
|
||||||
|
const list = await createList(db, userId, { name: "Desert" });
|
||||||
|
await addListItem(db, userId, list.id, { workId: draft.workId });
|
||||||
|
await acceptSuggestion(db, userId, await suggest(db, userId, draft.workId, target));
|
||||||
|
expect((await listCopies(db, userId, { workId: target })).map((c) => c.id)).toEqual([copy.id]);
|
||||||
|
const [ch] = await getHistory(db, userId, "copy", copy.id);
|
||||||
|
expect(ch!.after).toEqual({ workId: target });
|
||||||
|
expect((await getList(db, userId, list.id)).items.map((i) => i.work.title)).toEqual(["Dune"]);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,114 @@
|
|||||||
|
import type { Db } from "@sttracker/db/db";
|
||||||
|
import { newId, nowIso } from "@sttracker/db/ids";
|
||||||
|
import type { Database } from "@sttracker/db/schema";
|
||||||
|
import { createTestContext } from "@sttracker/plugin-sdk/testing";
|
||||||
|
import { definePlugin, type SourcePlugin, type SourceRecord, type WorkHint } from "@sttracker/plugin-sdk/types";
|
||||||
|
import type { SyncDeps } from "../../src/sync/run.ts";
|
||||||
|
|
||||||
|
export { makeUser, makeWork } from "../user-layer-fixtures.ts";
|
||||||
|
|
||||||
|
/** Insert a `source_connections` row directly and return its id. */
|
||||||
|
export async function makeConnection(db: Db, userId: string, pluginId: string): Promise<string> {
|
||||||
|
const id = newId();
|
||||||
|
await db.k
|
||||||
|
.insertInto("source_connections")
|
||||||
|
.values({
|
||||||
|
id,
|
||||||
|
user_id: userId,
|
||||||
|
plugin_id: pluginId,
|
||||||
|
name: pluginId,
|
||||||
|
config: "{}",
|
||||||
|
credentials_enc: null,
|
||||||
|
schedule_minutes: null,
|
||||||
|
cursor: null,
|
||||||
|
status: "active",
|
||||||
|
last_run_at: null,
|
||||||
|
created_at: nowIso(),
|
||||||
|
})
|
||||||
|
.execute();
|
||||||
|
return id;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A source whose records the test mutates between runs. `failAfter` makes `pull` throw after that many records. */
|
||||||
|
export interface FakeSource {
|
||||||
|
records: SourceRecord[];
|
||||||
|
failAfter?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function fakePlugin(id: string, src: FakeSource): SourcePlugin {
|
||||||
|
async function* gen(): AsyncIterable<SourceRecord> {
|
||||||
|
let i = 0;
|
||||||
|
for (const r of src.records) {
|
||||||
|
if (src.failAfter !== undefined && i >= src.failAfter) throw new Error("source exploded");
|
||||||
|
yield r;
|
||||||
|
i++;
|
||||||
|
}
|
||||||
|
if (src.failAfter !== undefined && i >= src.failAfter) throw new Error("source exploded");
|
||||||
|
}
|
||||||
|
return definePlugin({
|
||||||
|
manifest: { id, name: id, version: "0.0.0", sdkVersion: 1, mediaTypes: ["book", "movie", "show"], capabilities: ["pull", "pullChanges"], config: [] },
|
||||||
|
pull: () => gen(),
|
||||||
|
pullChanges: () => gen(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function deps(db: Db): SyncDeps {
|
||||||
|
return { db, makeContext: () => createTestContext() };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function book(isbn: string, title = `Book ${isbn}`): WorkHint {
|
||||||
|
return { title, mediaType: "book", externalIds: { isbn13: isbn } };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function entryRec(key: string, work: WorkHint, entry: Extract<SourceRecord, { kind: "entry" }>["entry"], extra: Partial<SourceRecord> = {}): SourceRecord {
|
||||||
|
return { key, kind: "entry", work, entry, raw: { key, entry }, ...extra } as SourceRecord;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function engagementRec(
|
||||||
|
key: string,
|
||||||
|
work: WorkHint,
|
||||||
|
engagement: Extract<SourceRecord, { kind: "engagement" }>["engagement"],
|
||||||
|
): SourceRecord {
|
||||||
|
return { key, kind: "engagement", work, engagement, raw: { key } };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function listenRec(key: string, listen: Extract<SourceRecord, { kind: "listen" }>["listen"]): SourceRecord {
|
||||||
|
return { key, kind: "listen", listen, raw: { key } };
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function count(db: Db, table: keyof Database): Promise<number> {
|
||||||
|
const r = await db.k
|
||||||
|
.selectFrom(table)
|
||||||
|
.select((eb) => eb.fn.countAll<number>().as("n"))
|
||||||
|
.executeTakeFirstOrThrow();
|
||||||
|
return Number(r.n);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function events(db: Db, where: { type?: string; status?: string } = {}) {
|
||||||
|
let q = db.k.selectFrom("change_events").selectAll().orderBy("id");
|
||||||
|
if (where.type) q = q.where("type", "=", where.type as never);
|
||||||
|
if (where.status) q = q.where("status", "=", where.status as never);
|
||||||
|
const rows = await q.execute();
|
||||||
|
return rows.map((r) => ({ ...r, payload: JSON.parse(r.payload) as Record<string, unknown> }));
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function entryFor(db: Db, userId: string, isbn: string) {
|
||||||
|
const ext = await db.k
|
||||||
|
.selectFrom("external_ids")
|
||||||
|
.select("work_id")
|
||||||
|
.where("namespace", "=", "isbn13")
|
||||||
|
.where("value", "=", isbn)
|
||||||
|
.executeTakeFirst();
|
||||||
|
if (!ext) return undefined;
|
||||||
|
const row = await db.k
|
||||||
|
.selectFrom("entries")
|
||||||
|
.selectAll()
|
||||||
|
.where("user_id", "=", userId)
|
||||||
|
.where("work_id", "=", ext.work_id)
|
||||||
|
.executeTakeFirst();
|
||||||
|
return row ? { ...row, fieldSources: JSON.parse(row.field_sources) as Record<string, string> } : undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function listRec(key: string, name: string, items: { work: WorkHint; note?: string }[], description?: string): SourceRecord {
|
||||||
|
return { key, kind: "list", list: { name, items, ...(description !== undefined ? { description } : {}) }, raw: { key } };
|
||||||
|
}
|
||||||
@@ -0,0 +1,94 @@
|
|||||||
|
import { expect, test } from "bun:test";
|
||||||
|
import { forEachDialect } from "@sttracker/db/testing";
|
||||||
|
import { ingestListens } from "../../src/sync/listens.ts";
|
||||||
|
import { runSync } from "../../src/sync/run.ts";
|
||||||
|
import { count, deps, entryRec, book, fakePlugin, listenRec, makeConnection, makeUser, makeWork } from "./helpers.ts";
|
||||||
|
|
||||||
|
forEachDialect("listens", (getDb) => {
|
||||||
|
test("listens insert-or-ignore and mbid matching", async () => {
|
||||||
|
const db = getDb();
|
||||||
|
const userId = await makeUser(db);
|
||||||
|
const known = await makeWork(db, { title: "Known Song", mediaType: "track", externalIds: { musicbrainz_recording: "rec-1" } });
|
||||||
|
const records = [
|
||||||
|
listenRec("lb:1", { listenedAt: "2024-01-01T10:00:00Z", artist: "A", track: "Known song", mbids: { recording: "rec-1" } }),
|
||||||
|
listenRec("lb:2", { listenedAt: "2024-01-01T10:05:00Z", artist: "B", track: "New Song", album: "LP", durationMs: 1000, mbids: { recording: "rec-2" } }),
|
||||||
|
listenRec("lb:3", { listenedAt: "2024-01-01T10:10:00Z", artist: "C", track: "No Ids" }),
|
||||||
|
];
|
||||||
|
const works0 = await count(db, "works");
|
||||||
|
expect(await ingestListens(db, userId, "listenbrainz", records)).toEqual({ inserted: 3 });
|
||||||
|
|
||||||
|
const rows = await db.k.selectFrom("listens").selectAll().orderBy("source_key").execute();
|
||||||
|
expect(rows.map((r) => r.source_key)).toEqual(["lb:1", "lb:2", "lb:3"]);
|
||||||
|
expect(rows[0]!.work_id).toBe(known);
|
||||||
|
expect(rows[2]!.work_id).toBeNull();
|
||||||
|
expect(rows[1]!.album).toBe("LP");
|
||||||
|
expect(rows[1]!.duration_ms).toBe(1000);
|
||||||
|
expect(JSON.parse(rows[1]!.mbids)).toEqual({ recording: "rec-2" });
|
||||||
|
const created = await db.k.selectFrom("works").selectAll().where("id", "=", rows[1]!.work_id!).executeTakeFirstOrThrow();
|
||||||
|
expect(created.media_type).toBe("track");
|
||||||
|
expect(created.title).toBe("New Song");
|
||||||
|
expect(created.owner_user_id).toBeNull();
|
||||||
|
const ext = await db.k.selectFrom("external_ids").selectAll().where("work_id", "=", created.id).execute();
|
||||||
|
expect(ext.map((e) => [e.namespace, e.value])).toEqual([["musicbrainz_recording", "rec-2"]]);
|
||||||
|
expect(await count(db, "works")).toBe(works0 + 1);
|
||||||
|
// A listen never renames the shared work.
|
||||||
|
expect((await db.k.selectFrom("works").select("title").where("id", "=", known).executeTakeFirstOrThrow()).title).toBe("Known Song");
|
||||||
|
|
||||||
|
expect(await ingestListens(db, userId, "listenbrainz", records)).toEqual({ inserted: 0 });
|
||||||
|
expect(await count(db, "listens")).toBe(3);
|
||||||
|
expect(await count(db, "works")).toBe(works0 + 1);
|
||||||
|
expect(await count(db, "change_events")).toBe(0);
|
||||||
|
|
||||||
|
// Same key under another source, or for another user, is a separate listen.
|
||||||
|
expect(await ingestListens(db, userId, "lastfm", records.slice(0, 1))).toEqual({ inserted: 1 });
|
||||||
|
const bob = await makeUser(db, "bob");
|
||||||
|
expect(await ingestListens(db, bob, "listenbrainz", records.slice(0, 1))).toEqual({ inserted: 1 });
|
||||||
|
});
|
||||||
|
|
||||||
|
test("1200 listens ingest correctly and idempotently", async () => {
|
||||||
|
const db = getDb();
|
||||||
|
const userId = await makeUser(db);
|
||||||
|
const records = Array.from({ length: 1200 }, (_, i) =>
|
||||||
|
listenRec(`k${i}`, {
|
||||||
|
listenedAt: `2024-01-01T00:00:${String(i % 60).padStart(2, "0")}Z`,
|
||||||
|
artist: "A",
|
||||||
|
track: `T${i % 7}`,
|
||||||
|
...(i % 3 === 0 ? { mbids: { recording: `rec-${i % 7}` } } : {}),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
// A duplicate key inside the same call is inserted once.
|
||||||
|
records.push(records[5]!);
|
||||||
|
expect(await ingestListens(db, userId, "lb", records)).toEqual({ inserted: 1200 });
|
||||||
|
expect(await count(db, "listens")).toBe(1200);
|
||||||
|
expect(await count(db, "works")).toBe(7);
|
||||||
|
const linked = await db.k.selectFrom("listens").select("work_id").where("work_id", "is not", null).execute();
|
||||||
|
expect(linked).toHaveLength(400);
|
||||||
|
expect(await ingestListens(db, userId, "lb", records)).toEqual({ inserted: 0 });
|
||||||
|
expect(await count(db, "listens")).toBe(1200);
|
||||||
|
expect(await count(db, "works")).toBe(7);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("runSync routes listens to ingestListens and counts them", async () => {
|
||||||
|
const db = getDb();
|
||||||
|
const userId = await makeUser(db);
|
||||||
|
const connectionId = await makeConnection(db, userId, "lb");
|
||||||
|
const src = {
|
||||||
|
records: [
|
||||||
|
listenRec("1", { listenedAt: "2024-01-01T10:00:00Z", artist: "A", track: "T" }),
|
||||||
|
listenRec("2", { listenedAt: "2024-01-01T10:01:00Z", artist: "A", track: "U" }),
|
||||||
|
entryRec("e", book("1"), { rating: 50 }),
|
||||||
|
],
|
||||||
|
};
|
||||||
|
const s = await runSync(deps(db), fakePlugin("lb", src), connectionId, "full");
|
||||||
|
expect(s.counts.listens).toBe(2);
|
||||||
|
expect(s.counts.seen).toBe(3);
|
||||||
|
const rows = await db.k.selectFrom("listens").select(["source", "source_key"]).orderBy("source_key").execute();
|
||||||
|
expect(rows).toEqual([{ source: "lb", source_key: "1" }, { source: "lb", source_key: "2" }]);
|
||||||
|
// Listens are not snapshotted as source records and never removed.
|
||||||
|
expect(await count(db, "source_records")).toBe(1);
|
||||||
|
src.records = [];
|
||||||
|
const s2 = await runSync(deps(db), fakePlugin("lb", src), connectionId, "full");
|
||||||
|
expect(s2.counts.removed).toBe(1);
|
||||||
|
expect(await count(db, "listens")).toBe(2);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,611 @@
|
|||||||
|
import { expect, test } from "bun:test";
|
||||||
|
import { forEachDialect } from "@sttracker/db/testing";
|
||||||
|
import { moveEntryToWork, softDeleteEntry, upsertEntry } from "../../src/entries.ts";
|
||||||
|
import { updateList } from "../../src/lists.ts";
|
||||||
|
import { listChanges, resolveChange } from "../../src/sync/changes.ts";
|
||||||
|
import { runSync } from "../../src/sync/run.ts";
|
||||||
|
import { NotFoundError, ValidationError } from "../../src/errors.ts";
|
||||||
|
import {
|
||||||
|
book,
|
||||||
|
count,
|
||||||
|
deps,
|
||||||
|
engagementRec,
|
||||||
|
entryFor,
|
||||||
|
entryRec,
|
||||||
|
events,
|
||||||
|
fakePlugin,
|
||||||
|
type FakeSource,
|
||||||
|
listenRec,
|
||||||
|
listRec,
|
||||||
|
makeConnection,
|
||||||
|
makeUser,
|
||||||
|
makeWork,
|
||||||
|
} from "./helpers.ts";
|
||||||
|
|
||||||
|
forEachDialect("sync engine", (getDb) => {
|
||||||
|
async function setup(pluginId = "fake") {
|
||||||
|
const db = getDb();
|
||||||
|
const userId = await makeUser(db);
|
||||||
|
const connectionId = await makeConnection(db, userId, pluginId);
|
||||||
|
const src: FakeSource = { records: [] };
|
||||||
|
const plugin = fakePlugin(pluginId, src);
|
||||||
|
const actor = `plugin:${pluginId}:${connectionId}`;
|
||||||
|
return { db, userId, connectionId, src, plugin, actor };
|
||||||
|
}
|
||||||
|
|
||||||
|
test("first full pull creates entries and auto_applied events", async () => {
|
||||||
|
const { db, userId, connectionId, src, plugin, actor } = await setup();
|
||||||
|
src.records = [
|
||||||
|
entryRec("e1", book("111"), { status: "completed", rating: 80, notes: "loved it" }),
|
||||||
|
engagementRec("g1", book("111"), { finishedAt: { value: "2024-01-05", precision: "day" } }),
|
||||||
|
];
|
||||||
|
const s = await runSync(deps(db), plugin, connectionId, "full");
|
||||||
|
expect(s.status).toBe("ok");
|
||||||
|
expect(s.counts).toEqual({ seen: 2, added: 2, changed: 0, removed: 0, merged: 0, conflicts: 0, autoApplied: 2, pending: 0, listens: 0 });
|
||||||
|
|
||||||
|
const e = await entryFor(db, userId, "111");
|
||||||
|
expect(e?.status).toBe("completed");
|
||||||
|
expect(e?.rating).toBe(80);
|
||||||
|
expect(e?.private_notes).toBe("loved it");
|
||||||
|
expect(e?.fieldSources).toEqual({ status: actor, rating: actor, privateNotes: actor });
|
||||||
|
|
||||||
|
const eg = await db.k.selectFrom("engagements").selectAll().execute();
|
||||||
|
expect(eg).toHaveLength(1);
|
||||||
|
expect(eg[0]!.entry_id).toBe(e!.id);
|
||||||
|
expect(eg[0]!.finished_at).toBe("2024-01-05");
|
||||||
|
expect(await count(db, "engagement_sources")).toBe(1);
|
||||||
|
|
||||||
|
const ev = await events(db);
|
||||||
|
expect(ev.map((x) => [x.type, x.status])).toEqual([["added", "auto_applied"], ["added", "auto_applied"]]);
|
||||||
|
expect(ev[0]!.run_id).toBe(s.runId);
|
||||||
|
expect(ev[0]!.target_entity).toBe("entry");
|
||||||
|
expect(ev[0]!.target_id).toBe(e!.id);
|
||||||
|
expect(ev[1]!.target_entity).toBe("engagement");
|
||||||
|
|
||||||
|
expect(await count(db, "source_records")).toBe(2);
|
||||||
|
expect(await count(db, "source_record_versions")).toBe(2);
|
||||||
|
const rec = await db.k.selectFrom("source_records").selectAll().where("key", "=", "e1").executeTakeFirstOrThrow();
|
||||||
|
expect(rec.target_entity).toBe("entry");
|
||||||
|
expect(rec.target_id).toBe(e!.id);
|
||||||
|
|
||||||
|
const run = await db.k.selectFrom("source_runs").selectAll().where("id", "=", s.runId).executeTakeFirstOrThrow();
|
||||||
|
expect(run.status).toBe("ok");
|
||||||
|
expect(run.mode).toBe("full");
|
||||||
|
expect(run.finished_at).not.toBeNull();
|
||||||
|
expect(JSON.parse(run.counts)).toEqual(s.counts);
|
||||||
|
const conn = await db.k.selectFrom("source_connections").selectAll().where("id", "=", connectionId).executeTakeFirstOrThrow();
|
||||||
|
expect(conn.last_run_at).not.toBeNull();
|
||||||
|
expect(conn.status).toBe("active");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("an entry on a child work also gets a status-none entry on the root ancestor", async () => {
|
||||||
|
const { db, userId, connectionId, src, plugin, actor } = await setup();
|
||||||
|
const show = { title: "Show", mediaType: "show" as const, externalIds: { tmdb_show: "9" } };
|
||||||
|
const season = { title: "Season 1", mediaType: "season" as const, externalIds: {}, parent: show, position: { season: 1 } };
|
||||||
|
const episode = { title: "Pilot", mediaType: "episode" as const, externalIds: { tmdb_episode: "91" }, parent: season, position: { episode: 1 } };
|
||||||
|
src.records = [engagementRec("ep1", episode, { finishedAt: { value: "2024-02-01T21:00:00Z", precision: "day" } })];
|
||||||
|
await runSync(deps(db), plugin, connectionId, "full");
|
||||||
|
|
||||||
|
const rows = await db.k
|
||||||
|
.selectFrom("entries as e")
|
||||||
|
.innerJoin("works as w", "w.id", "e.work_id")
|
||||||
|
.select(["w.media_type", "e.status", "e.field_sources"])
|
||||||
|
.where("e.user_id", "=", userId)
|
||||||
|
.orderBy("w.media_type")
|
||||||
|
.execute();
|
||||||
|
expect(rows.map((r) => [r.media_type, r.status])).toEqual([["episode", "none"], ["show", "none"]]);
|
||||||
|
const hist = await db.k.selectFrom("user_history").select("actor").execute();
|
||||||
|
expect(new Set(hist.map((h) => h.actor))).toEqual(new Set([actor]));
|
||||||
|
|
||||||
|
// An existing (even deleted) root entry is never touched.
|
||||||
|
const showEntry = await db.k
|
||||||
|
.selectFrom("entries as e")
|
||||||
|
.innerJoin("works as w", "w.id", "e.work_id")
|
||||||
|
.select("e.id")
|
||||||
|
.where("w.media_type", "=", "show")
|
||||||
|
.executeTakeFirstOrThrow();
|
||||||
|
await softDeleteEntry(db, userId, showEntry.id, "user");
|
||||||
|
src.records.push(engagementRec("ep2", { ...episode, title: "Two", externalIds: { tmdb_episode: "92" }, position: { episode: 2 } }, {}));
|
||||||
|
await runSync(deps(db), plugin, connectionId, "full");
|
||||||
|
const show2 = await db.k.selectFrom("entries").selectAll().where("id", "=", showEntry.id).executeTakeFirstOrThrow();
|
||||||
|
expect(show2.deleted_at).not.toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("second identical pull creates nothing new", async () => {
|
||||||
|
const { db, connectionId, src, plugin } = await setup();
|
||||||
|
src.records = [
|
||||||
|
entryRec("e1", book("111"), { status: "completed", rating: 80 }),
|
||||||
|
engagementRec("g1", book("111"), { finishedAt: { value: "2024-01-05", precision: "day" } }),
|
||||||
|
listenRec("l1", { listenedAt: "2024-01-01T10:00:00Z", artist: "A", track: "T" }),
|
||||||
|
];
|
||||||
|
const first = await runSync(deps(db), plugin, connectionId, "full");
|
||||||
|
expect(first.counts.listens).toBe(1);
|
||||||
|
const tables = ["source_records", "source_record_versions", "change_events", "entries", "engagements", "engagement_sources", "listens", "user_history", "works"] as const;
|
||||||
|
const before = await Promise.all(tables.map((t) => count(db, t)));
|
||||||
|
const second = await runSync(deps(db), plugin, connectionId, "full");
|
||||||
|
const after = await Promise.all(tables.map((t) => count(db, t)));
|
||||||
|
expect(after).toEqual(before);
|
||||||
|
expect(second.status).toBe("ok");
|
||||||
|
expect(second.counts).toEqual({ seen: 3, added: 0, changed: 0, removed: 0, merged: 0, conflicts: 0, autoApplied: 0, pending: 0, listens: 0 });
|
||||||
|
expect(await count(db, "source_runs")).toBe(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("failed pull marks nothing removed", async () => {
|
||||||
|
const { db, connectionId, src, plugin } = await setup();
|
||||||
|
src.records = [entryRec("a", book("1"), { status: "completed" }), entryRec("b", book("2"), { status: "planned" })];
|
||||||
|
await runSync(deps(db), plugin, connectionId, "full");
|
||||||
|
|
||||||
|
src.failAfter = 1;
|
||||||
|
const s = await runSync(deps(db), plugin, connectionId, "full");
|
||||||
|
expect(s.status).toBe("failed");
|
||||||
|
expect(s.error).toContain("source exploded");
|
||||||
|
expect(s.counts.removed).toBe(0);
|
||||||
|
const deleted = await db.k.selectFrom("source_records").select("key").where("deleted_at", "is not", null).execute();
|
||||||
|
expect(deleted).toEqual([]);
|
||||||
|
expect(await events(db, { type: "removed" })).toEqual([]);
|
||||||
|
const run = await db.k.selectFrom("source_runs").selectAll().where("id", "=", s.runId).executeTakeFirstOrThrow();
|
||||||
|
expect(run.status).toBe("failed");
|
||||||
|
expect(run.error).toContain("source exploded");
|
||||||
|
const conn = await db.k.selectFrom("source_connections").selectAll().where("id", "=", connectionId).executeTakeFirstOrThrow();
|
||||||
|
expect(conn.status).toBe("error");
|
||||||
|
|
||||||
|
// A later successful run puts the connection back to active.
|
||||||
|
src.failAfter = undefined;
|
||||||
|
expect((await runSync(deps(db), plugin, connectionId, "full")).status).toBe("ok");
|
||||||
|
const conn2 = await db.k.selectFrom("source_connections").selectAll().where("id", "=", connectionId).executeTakeFirstOrThrow();
|
||||||
|
expect(conn2.status).toBe("active");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("record missing from completed full pull → pending removed; accept soft-deletes entry", async () => {
|
||||||
|
const { db, userId, connectionId, src, plugin } = await setup();
|
||||||
|
src.records = [entryRec("a", book("1"), { status: "completed" }), entryRec("b", book("2"), { status: "planned" })];
|
||||||
|
await runSync(deps(db), plugin, connectionId, "full");
|
||||||
|
|
||||||
|
src.records = [src.records[0]!];
|
||||||
|
// A "changes" pull never infers removal from absence.
|
||||||
|
expect((await runSync(deps(db), plugin, connectionId, "changes")).counts.removed).toBe(0);
|
||||||
|
expect(await events(db, { type: "removed" })).toEqual([]);
|
||||||
|
|
||||||
|
const s = await runSync(deps(db), plugin, connectionId, "full");
|
||||||
|
expect(s.counts.removed).toBe(1);
|
||||||
|
expect(s.counts.pending).toBe(1);
|
||||||
|
const rec = await db.k.selectFrom("source_records").selectAll().where("key", "=", "b").executeTakeFirstOrThrow();
|
||||||
|
expect(rec.deleted_at).not.toBeNull();
|
||||||
|
const removed = await events(db, { type: "removed" });
|
||||||
|
expect(removed).toHaveLength(1);
|
||||||
|
expect(removed[0]!.status).toBe("pending");
|
||||||
|
const entryB = await entryFor(db, userId, "2");
|
||||||
|
expect(removed[0]!.target_id).toBe(entryB!.id);
|
||||||
|
expect(entryB!.deleted_at).toBeNull(); // nothing deleted without the user's say
|
||||||
|
|
||||||
|
// Rerun: not re-filed.
|
||||||
|
await runSync(deps(db), plugin, connectionId, "full");
|
||||||
|
expect(await events(db, { type: "removed" })).toHaveLength(1);
|
||||||
|
|
||||||
|
await resolveChange(db, userId, removed[0]!.id, "accept");
|
||||||
|
expect((await entryFor(db, userId, "2"))!.deleted_at).not.toBeNull();
|
||||||
|
const ev = (await events(db, { type: "removed" }))[0]!;
|
||||||
|
expect(ev.status).toBe("accepted");
|
||||||
|
expect(ev.resolved_at).not.toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("deleted: true in a changes pull files a pending removed event", async () => {
|
||||||
|
const { db, userId, connectionId, src, plugin } = await setup();
|
||||||
|
src.records = [entryRec("a", book("1"), { status: "completed" })];
|
||||||
|
await runSync(deps(db), plugin, connectionId, "full");
|
||||||
|
src.records = [entryRec("a", book("1"), { status: "completed" }, { deleted: true })];
|
||||||
|
const s = await runSync(deps(db), plugin, connectionId, "changes");
|
||||||
|
expect(s.counts.removed).toBe(1);
|
||||||
|
const removed = await events(db, { type: "removed", status: "pending" });
|
||||||
|
expect(removed).toHaveLength(1);
|
||||||
|
await resolveChange(db, userId, removed[0]!.id, "ignore");
|
||||||
|
expect((await entryFor(db, userId, "1"))!.deleted_at).toBeNull();
|
||||||
|
expect((await events(db, { type: "removed" }))[0]!.status).toBe("ignored");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("user-edited rating not overwritten; pending changed event", async () => {
|
||||||
|
const { db, userId, connectionId, src, plugin, actor } = await setup();
|
||||||
|
src.records = [entryRec("a", book("1"), { status: "in_progress", rating: 80 })];
|
||||||
|
await runSync(deps(db), plugin, connectionId, "full");
|
||||||
|
const e0 = (await entryFor(db, userId, "1"))!;
|
||||||
|
await upsertEntry(db, userId, e0.work_id, { rating: 90 }, "user");
|
||||||
|
|
||||||
|
src.records = [entryRec("a", book("1"), { status: "completed", rating: 70 })];
|
||||||
|
const s = await runSync(deps(db), plugin, connectionId, "full");
|
||||||
|
expect(s.counts.changed).toBe(1);
|
||||||
|
expect(s.counts.autoApplied).toBe(1);
|
||||||
|
expect(s.counts.pending).toBe(1);
|
||||||
|
const e1 = (await entryFor(db, userId, "1"))!;
|
||||||
|
expect(e1.rating).toBe(90);
|
||||||
|
expect(e1.status).toBe("completed"); // never edited by the user: applied
|
||||||
|
expect(e1.fieldSources).toEqual({ status: actor, rating: "user" });
|
||||||
|
const pending = await events(db, { type: "changed", status: "pending" });
|
||||||
|
expect(pending).toHaveLength(1);
|
||||||
|
expect(pending[0]!.payload).toMatchObject({ field: "rating", from: 90, to: 70 });
|
||||||
|
const applied = await events(db, { type: "changed", status: "auto_applied" });
|
||||||
|
expect(applied.map((x) => x.payload)).toEqual([expect.objectContaining({ field: "status", from: "in_progress", to: "completed" })]);
|
||||||
|
|
||||||
|
// Rerun: nothing new.
|
||||||
|
await runSync(deps(db), plugin, connectionId, "full");
|
||||||
|
expect(await events(db, { type: "changed", status: "pending" })).toHaveLength(1);
|
||||||
|
|
||||||
|
const listed = await listChanges(db, userId, {});
|
||||||
|
expect(listed.items.map((c) => c.id)).toEqual([pending[0]!.id]);
|
||||||
|
|
||||||
|
await resolveChange(db, userId, pending[0]!.id, "accept");
|
||||||
|
const e2 = (await entryFor(db, userId, "1"))!;
|
||||||
|
expect(e2.rating).toBe(70);
|
||||||
|
expect(e2.fieldSources.rating).toBe(actor);
|
||||||
|
expect((await listChanges(db, userId, {})).items).toEqual([]);
|
||||||
|
await expect(resolveChange(db, userId, pending[0]!.id, "accept")).rejects.toBeInstanceOf(ValidationError);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("a user-deleted entry is never revived by a sync", async () => {
|
||||||
|
const { db, userId, connectionId, src, plugin } = await setup();
|
||||||
|
src.records = [entryRec("a", book("1"), { rating: 80 })];
|
||||||
|
await runSync(deps(db), plugin, connectionId, "full");
|
||||||
|
const e = (await entryFor(db, userId, "1"))!;
|
||||||
|
await softDeleteEntry(db, userId, e.id, "user");
|
||||||
|
const before = await count(db, "change_events");
|
||||||
|
// A changed record, a brand-new entry record and a new engagement record, all on the deleted entry's work.
|
||||||
|
src.records = [
|
||||||
|
entryRec("a", book("1"), { rating: 50 }),
|
||||||
|
entryRec("a2", book("1"), { status: "completed" }),
|
||||||
|
engagementRec("g", book("1"), { finishedAt: { value: "2024-01-01", precision: "day" } }),
|
||||||
|
];
|
||||||
|
const s = await runSync(deps(db), plugin, connectionId, "full");
|
||||||
|
expect(s.status).toBe("ok");
|
||||||
|
const after = (await entryFor(db, userId, "1"))!;
|
||||||
|
expect(after.deleted_at).not.toBeNull();
|
||||||
|
expect(after.rating).toBe(80);
|
||||||
|
expect(after.status).toBe("none");
|
||||||
|
expect(await count(db, "engagements")).toBe(0);
|
||||||
|
expect(await count(db, "change_events")).toBe(before);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("an id-less record is updated through its mapping, not re-matched", async () => {
|
||||||
|
const { db, userId, connectionId, src, plugin } = await setup();
|
||||||
|
const hint = { title: "Obscure Zine", mediaType: "book" as const, externalIds: {} };
|
||||||
|
src.records = [entryRec("csv-1", hint, { rating: 40 })];
|
||||||
|
await runSync(deps(db), plugin, connectionId, "full");
|
||||||
|
const works = await count(db, "works");
|
||||||
|
src.records = [entryRec("csv-1", hint, { rating: 60 })];
|
||||||
|
await runSync(deps(db), plugin, connectionId, "full");
|
||||||
|
expect(await count(db, "works")).toBe(works);
|
||||||
|
const rows = await db.k.selectFrom("entries").selectAll().where("user_id", "=", userId).execute();
|
||||||
|
expect(rows).toHaveLength(1);
|
||||||
|
expect(rows[0]!.rating).toBe(60);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("two connections disagree → one pending conflict, not duplicated on rerun; keep_mine stops further conflicts for that value", async () => {
|
||||||
|
const { db, userId, connectionId: connA, src: srcA, plugin: pluginA, actor: actorA } = await setup("alpha");
|
||||||
|
const connB = await makeConnection(db, userId, "beta");
|
||||||
|
const srcB: FakeSource = { records: [] };
|
||||||
|
const pluginB = fakePlugin("beta", srcB);
|
||||||
|
const actorB = `plugin:beta:${connB}`;
|
||||||
|
|
||||||
|
srcA.records = [entryRec("a", book("1"), { rating: 80 })];
|
||||||
|
await runSync(deps(db), pluginA, connA, "full");
|
||||||
|
srcB.records = [entryRec("b", book("1"), { rating: 60, status: "completed" })];
|
||||||
|
const s = await runSync(deps(db), pluginB, connB, "full");
|
||||||
|
expect(s.counts.conflicts).toBe(1);
|
||||||
|
|
||||||
|
const e = (await entryFor(db, userId, "1"))!;
|
||||||
|
expect(e.rating).toBe(80);
|
||||||
|
expect(e.status).toBe("completed"); // field nobody had set: applied
|
||||||
|
expect(e.fieldSources).toEqual({ rating: actorA, status: actorB });
|
||||||
|
let conflicts = await events(db, { type: "conflict", status: "pending" });
|
||||||
|
expect(conflicts).toHaveLength(1);
|
||||||
|
expect(conflicts[0]!.payload).toMatchObject({ field: "rating", current: 80, currentSource: actorA, incoming: 60, incomingSource: actorB });
|
||||||
|
|
||||||
|
// Rerun both: nothing new.
|
||||||
|
await runSync(deps(db), pluginA, connA, "full");
|
||||||
|
await runSync(deps(db), pluginB, connB, "full");
|
||||||
|
expect(await events(db, { type: "conflict" })).toHaveLength(1);
|
||||||
|
|
||||||
|
// B moves 60 → 70: the stale pending conflict is superseded, not left beside the new one.
|
||||||
|
srcB.records = [entryRec("b", book("1"), { rating: 70, status: "completed" })];
|
||||||
|
await runSync(deps(db), pluginB, connB, "full");
|
||||||
|
let all = await events(db, { type: "conflict" });
|
||||||
|
expect(all.map((c) => [c.payload.incoming, c.status])).toEqual([[60, "ignored"], [70, "pending"]]);
|
||||||
|
expect(all[0]!.payload.supersededBy).toBe(all[1]!.id);
|
||||||
|
expect(all[0]!.resolved_at).not.toBeNull();
|
||||||
|
|
||||||
|
// And back 70 → 60: still exactly one pending conflict for the field.
|
||||||
|
srcB.records = [entryRec("b", book("1"), { rating: 60, status: "completed" })];
|
||||||
|
await runSync(deps(db), pluginB, connB, "full");
|
||||||
|
conflicts = await events(db, { type: "conflict", status: "pending" });
|
||||||
|
expect(conflicts.map((c) => c.payload.incoming)).toEqual([60]);
|
||||||
|
|
||||||
|
await resolveChange(db, userId, conflicts[0]!.id, "keep_mine");
|
||||||
|
const kept = (await entryFor(db, userId, "1"))!;
|
||||||
|
expect(kept.rating).toBe(80);
|
||||||
|
expect(kept.fieldSources.rating).toBe("user");
|
||||||
|
all = await events(db, { type: "conflict" });
|
||||||
|
expect(all.find((c) => c.id === conflicts[0]!.id)!.status).toBe("kept_mine");
|
||||||
|
|
||||||
|
// Reruns file nothing; a NEW value from B is now a user-decided pending change, not a conflict.
|
||||||
|
await runSync(deps(db), pluginA, connA, "full");
|
||||||
|
await runSync(deps(db), pluginB, connB, "full");
|
||||||
|
expect(await events(db, { type: "conflict" })).toHaveLength(3);
|
||||||
|
srcB.records = [entryRec("b", book("1"), { rating: 65, status: "completed" })];
|
||||||
|
await runSync(deps(db), pluginB, connB, "full");
|
||||||
|
expect(await events(db, { type: "conflict" })).toHaveLength(3);
|
||||||
|
const changed = await events(db, { type: "changed", status: "pending" });
|
||||||
|
expect(changed).toHaveLength(1);
|
||||||
|
expect(changed[0]!.payload).toMatchObject({ field: "rating", from: 80, to: 65 });
|
||||||
|
expect((await entryFor(db, userId, "1"))!.rating).toBe(80);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("same play from two sources dedupes into one engagement with two engagement_sources", async () => {
|
||||||
|
const { db, userId, connectionId: connA, src: srcA, plugin: pluginA } = await setup("alpha");
|
||||||
|
const connB = await makeConnection(db, userId, "beta");
|
||||||
|
const srcB: FakeSource = { records: [] };
|
||||||
|
const pluginB = fakePlugin("beta", srcB);
|
||||||
|
|
||||||
|
srcA.records = [engagementRec("play-1", book("1"), { finishedAt: { value: "2024-03-01T20:00:00Z", precision: "day" } })];
|
||||||
|
await runSync(deps(db), pluginA, connA, "full");
|
||||||
|
srcB.records = [
|
||||||
|
engagementRec("row-7", book("1"), { finishedAt: { value: "2024-03-02", precision: "day" }, rating: 90 }),
|
||||||
|
engagementRec("row-8", book("1"), { finishedAt: { value: "2024-03-10", precision: "day" } }),
|
||||||
|
];
|
||||||
|
await runSync(deps(db), pluginB, connB, "full");
|
||||||
|
|
||||||
|
const egs = await db.k.selectFrom("engagements").selectAll().orderBy("finished_at").execute();
|
||||||
|
expect(egs).toHaveLength(2);
|
||||||
|
expect(egs[0]!.finished_at).toBe("2024-03-01T20:00:00Z"); // first source's value kept
|
||||||
|
expect(egs[0]!.rating).toBe(90); // empty field filled from the second source
|
||||||
|
const srcs = await db.k.selectFrom("engagement_sources").selectAll().where("engagement_id", "=", egs[0]!.id).execute();
|
||||||
|
expect(srcs.map((x) => x.record_key).sort()).toEqual(["play-1", "row-7"]);
|
||||||
|
expect(await count(db, "engagement_sources")).toBe(3);
|
||||||
|
|
||||||
|
// Equal start datetime also dedupes.
|
||||||
|
srcA.records.push(engagementRec("play-2", book("1"), { startedAt: { value: "2024-05-01T08:00:00Z", precision: "day" } }));
|
||||||
|
srcB.records.push(engagementRec("row-9", book("1"), { startedAt: { value: "2024-05-01T08:00:00Z", precision: "day" } }));
|
||||||
|
await runSync(deps(db), pluginA, connA, "full");
|
||||||
|
await runSync(deps(db), pluginB, connB, "full");
|
||||||
|
expect(await count(db, "engagements")).toBe(3);
|
||||||
|
expect(await count(db, "engagement_sources")).toBe(5);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("merged record → pending merged; accept moves entry", async () => {
|
||||||
|
const { db, userId, connectionId, src, plugin } = await setup();
|
||||||
|
src.records = [entryRec("x", book("1"), { status: "completed" })];
|
||||||
|
await runSync(deps(db), plugin, connectionId, "full");
|
||||||
|
const ex = (await entryFor(db, userId, "1"))!;
|
||||||
|
|
||||||
|
// The source merged x into y. Record y is new and creates its own (empty) entry on book 2.
|
||||||
|
src.records = [entryRec("x", book("1"), { status: "completed" }, { mergedIntoKey: "y" }), entryRec("y", book("2"), {})];
|
||||||
|
const s = await runSync(deps(db), plugin, connectionId, "full");
|
||||||
|
expect(s.counts.merged).toBe(1);
|
||||||
|
const merged = await events(db, { type: "merged", status: "pending" });
|
||||||
|
expect(merged).toHaveLength(1);
|
||||||
|
expect(merged[0]!.target_id).toBe(ex.id);
|
||||||
|
expect(merged[0]!.payload).toMatchObject({ intoKey: "y" });
|
||||||
|
expect((await entryFor(db, userId, "1"))!.deleted_at).toBeNull();
|
||||||
|
|
||||||
|
await resolveChange(db, userId, merged[0]!.id, "accept");
|
||||||
|
const ey = (await entryFor(db, userId, "2"))!;
|
||||||
|
expect(ey.deleted_at).toBeNull();
|
||||||
|
expect(ey.status).toBe("completed"); // filled from the moved entry
|
||||||
|
expect((await entryFor(db, userId, "1"))!.deleted_at).not.toBeNull();
|
||||||
|
expect((await events(db, { type: "merged" }))[0]!.status).toBe("accepted");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("a deduped play with a disagreeing value files a conflict instead of dropping it", async () => {
|
||||||
|
const { db, userId, connectionId: connA, src: srcA, plugin: pluginA, actor: actorA } = await setup("alpha");
|
||||||
|
const connB = await makeConnection(db, userId, "beta");
|
||||||
|
const pluginB = fakePlugin("beta", { records: [engagementRec("r", book("1"), { finishedAt: { value: "2024-03-01", precision: "day" }, rating: 90 })] });
|
||||||
|
srcA.records = [engagementRec("p", book("1"), { finishedAt: { value: "2024-03-01T20:00:00Z", precision: "day" }, rating: 80 })];
|
||||||
|
await runSync(deps(db), pluginA, connA, "full");
|
||||||
|
const s = await runSync(deps(db), pluginB, connB, "full");
|
||||||
|
expect(s.counts.conflicts).toBe(1);
|
||||||
|
const egs = await db.k.selectFrom("engagements").selectAll().execute();
|
||||||
|
expect(egs).toHaveLength(1);
|
||||||
|
expect(egs[0]!.rating).toBe(80);
|
||||||
|
expect(egs[0]!.finished_at).toBe("2024-03-01T20:00:00Z"); // the matching date is not a conflict
|
||||||
|
const c = await events(db, { type: "conflict", status: "pending" });
|
||||||
|
expect(c).toHaveLength(1);
|
||||||
|
expect(c[0]!.target_entity).toBe("engagement");
|
||||||
|
expect(c[0]!.payload).toMatchObject({ field: "rating", current: 80, currentSource: actorA, incoming: 90 });
|
||||||
|
});
|
||||||
|
|
||||||
|
test("a merged-away record that vanishes files no removal; the survivor is untouched", async () => {
|
||||||
|
const { db, userId, connectionId, src, plugin } = await setup();
|
||||||
|
src.records = [entryRec("x", book("1"), { status: "completed" })];
|
||||||
|
await runSync(deps(db), plugin, connectionId, "full");
|
||||||
|
src.records = [entryRec("x", book("1"), { status: "completed" }, { mergedIntoKey: "y" }), entryRec("y", book("2"), {})];
|
||||||
|
await runSync(deps(db), plugin, connectionId, "full");
|
||||||
|
// Before the user decides, x vanishing files nothing either.
|
||||||
|
src.records = [entryRec("y", book("2"), {})];
|
||||||
|
expect((await runSync(deps(db), plugin, connectionId, "full")).counts.removed).toBe(0);
|
||||||
|
expect(await events(db, { type: "removed" })).toEqual([]);
|
||||||
|
|
||||||
|
const [merged] = await events(db, { type: "merged", status: "pending" });
|
||||||
|
await resolveChange(db, userId, merged!.id, "accept");
|
||||||
|
const survivor = (await entryFor(db, userId, "2"))!;
|
||||||
|
const s = await runSync(deps(db), plugin, connectionId, "full");
|
||||||
|
expect(s.counts.removed).toBe(0);
|
||||||
|
expect(await events(db, { type: "removed" })).toEqual([]);
|
||||||
|
const after = (await entryFor(db, userId, "2"))!;
|
||||||
|
expect(after.deleted_at).toBeNull();
|
||||||
|
expect(after.status).toBe(survivor.status);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("an id-less record whose apply fails is retried against the same draft", async () => {
|
||||||
|
const { db, userId, connectionId, src, plugin } = await setup();
|
||||||
|
const hint = { title: "Obscure Zine", mediaType: "book" as const, externalIds: {} };
|
||||||
|
src.records = [entryRec("csv-1", hint, { rating: 150 })];
|
||||||
|
const failed = await runSync(deps(db), plugin, connectionId, "full");
|
||||||
|
expect(failed.status).toBe("failed");
|
||||||
|
expect(failed.counts.added).toBe(0);
|
||||||
|
src.records = [entryRec("csv-1", hint, { rating: 60 })];
|
||||||
|
const ok = await runSync(deps(db), plugin, connectionId, "full");
|
||||||
|
expect(ok.status).toBe("ok");
|
||||||
|
expect(ok.counts.added).toBe(1);
|
||||||
|
const drafts = await db.k.selectFrom("works").select("id").where("owner_user_id", "=", userId).execute();
|
||||||
|
expect(drafts).toHaveLength(1);
|
||||||
|
const e = await db.k.selectFrom("entries").selectAll().where("user_id", "=", userId).execute();
|
||||||
|
expect(e.map((x) => [x.work_id, x.rating])).toEqual([[drafts[0]!.id, 60]]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("a record-level error fails the run and skips removal", async () => {
|
||||||
|
const { db, connectionId, src, plugin } = await setup();
|
||||||
|
src.records = [entryRec("a", book("1"), { status: "completed" }), entryRec("b", book("2"), { status: "planned" })];
|
||||||
|
await runSync(deps(db), plugin, connectionId, "full");
|
||||||
|
src.records = [entryRec("a", book("1"), { status: "completed", rating: 999 })];
|
||||||
|
const s = await runSync(deps(db), plugin, connectionId, "full");
|
||||||
|
expect(s.status).toBe("failed");
|
||||||
|
expect(s.error).toContain("record a");
|
||||||
|
expect(s.counts.removed).toBe(0);
|
||||||
|
expect(s.counts.changed).toBe(0); // the failed record's work rolled back, and is not counted
|
||||||
|
expect(await events(db, { type: "removed" })).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("after the user move-merges an entry, the source keeps syncing into the survivor", async () => {
|
||||||
|
const { db, userId, connectionId, src, plugin, actor } = await setup();
|
||||||
|
src.records = [entryRec("a", book("1"), { rating: 50 })];
|
||||||
|
await runSync(deps(db), plugin, connectionId, "full");
|
||||||
|
const e1 = (await entryFor(db, userId, "1"))!;
|
||||||
|
const w2 = await makeWork(db, { title: "Other" });
|
||||||
|
const e2 = await upsertEntry(db, userId, w2, { status: "planned" }, "user");
|
||||||
|
await moveEntryToWork(db, userId, e1.id, w2, "user");
|
||||||
|
const rec = await db.k.selectFrom("source_records").selectAll().where("key", "=", "a").executeTakeFirstOrThrow();
|
||||||
|
expect(rec.target_id).toBe(e2.id);
|
||||||
|
|
||||||
|
src.records = [entryRec("a", book("1"), { rating: 60 })];
|
||||||
|
const s = await runSync(deps(db), plugin, connectionId, "full");
|
||||||
|
expect(s.counts.autoApplied).toBe(1);
|
||||||
|
const survivor = await db.k.selectFrom("entries").selectAll().where("id", "=", e2.id).executeTakeFirstOrThrow();
|
||||||
|
expect(survivor.rating).toBe(60);
|
||||||
|
expect(JSON.parse(survivor.field_sources).rating).toBe(actor);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("accepting removal of a play another source still reports only detaches this source", async () => {
|
||||||
|
const { db, userId, connectionId: connA, src: srcA, plugin: pluginA } = await setup("alpha");
|
||||||
|
const connB = await makeConnection(db, userId, "beta");
|
||||||
|
const srcB: FakeSource = { records: [engagementRec("r", book("1"), { finishedAt: { value: "2024-03-01", precision: "day" } })] };
|
||||||
|
const pluginB = fakePlugin("beta", srcB);
|
||||||
|
srcA.records = [engagementRec("p", book("1"), { finishedAt: { value: "2024-03-01", precision: "day" } })];
|
||||||
|
await runSync(deps(db), pluginA, connA, "full");
|
||||||
|
await runSync(deps(db), pluginB, connB, "full");
|
||||||
|
const [eg] = await db.k.selectFrom("engagements").selectAll().execute();
|
||||||
|
|
||||||
|
srcA.records = [];
|
||||||
|
await runSync(deps(db), pluginA, connA, "full");
|
||||||
|
const [removedA] = await events(db, { type: "removed", status: "pending" });
|
||||||
|
expect(removedA!.target_id).toBe(eg!.id);
|
||||||
|
await resolveChange(db, userId, removedA!.id, "accept");
|
||||||
|
let row = await db.k.selectFrom("engagements").selectAll().where("id", "=", eg!.id).executeTakeFirstOrThrow();
|
||||||
|
expect(row.deleted_at).toBeNull();
|
||||||
|
const left = await db.k.selectFrom("engagement_sources").select("connection_id").where("engagement_id", "=", eg!.id).execute();
|
||||||
|
expect(left.map((x) => x.connection_id)).toEqual([connB]);
|
||||||
|
|
||||||
|
srcB.records = [];
|
||||||
|
await runSync(deps(db), pluginB, connB, "full");
|
||||||
|
const [removedB] = await events(db, { type: "removed", status: "pending" });
|
||||||
|
await resolveChange(db, userId, removedB!.id, "accept");
|
||||||
|
row = await db.k.selectFrom("engagements").selectAll().where("id", "=", eg!.id).executeTakeFirstOrThrow();
|
||||||
|
expect(row.deleted_at).not.toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("accepting a change whose target is gone resolves it as ignored (target_gone)", async () => {
|
||||||
|
const { db, userId, connectionId, src, plugin } = await setup();
|
||||||
|
src.records = [entryRec("a", book("1"), { rating: 80 })];
|
||||||
|
await runSync(deps(db), plugin, connectionId, "full");
|
||||||
|
const e = (await entryFor(db, userId, "1"))!;
|
||||||
|
await upsertEntry(db, userId, e.work_id, { rating: 90 }, "user");
|
||||||
|
src.records = [entryRec("a", book("1"), { rating: 70 })];
|
||||||
|
await runSync(deps(db), plugin, connectionId, "full");
|
||||||
|
const [pending] = await events(db, { type: "changed", status: "pending" });
|
||||||
|
await softDeleteEntry(db, userId, e.id, "user");
|
||||||
|
await resolveChange(db, userId, pending!.id, "accept");
|
||||||
|
const [ev] = await events(db, { type: "changed" });
|
||||||
|
expect(ev!.status).toBe("ignored");
|
||||||
|
expect(ev!.payload.reason).toBe("target_gone");
|
||||||
|
expect(ev!.resolved_at).not.toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("list records: create, update, user-edited → pending, removed", async () => {
|
||||||
|
const { db, userId, connectionId, src, plugin, actor } = await setup();
|
||||||
|
const draftHint = { title: "Zine", mediaType: "book" as const, externalIds: {} };
|
||||||
|
src.records = [listRec("l1", "Favourites", [{ work: book("1") }, { work: draftHint, note: "rare" }])];
|
||||||
|
const s1 = await runSync(deps(db), plugin, connectionId, "full");
|
||||||
|
expect(s1.counts.added).toBe(1);
|
||||||
|
const list = await db.k.selectFrom("lists").selectAll().where("user_id", "=", userId).executeTakeFirstOrThrow();
|
||||||
|
expect([list.name, list.description, list.kind]).toEqual(["Favourites", "Imported from fake", "list"]);
|
||||||
|
const itemsOf = async () =>
|
||||||
|
(await db.k.selectFrom("list_items as i").innerJoin("works as w", "w.id", "i.work_id").select(["w.title", "i.note"]).where("i.list_id", "=", list.id).orderBy("i.position").execute()).map((r) => [r.title, r.note]);
|
||||||
|
expect(await itemsOf()).toEqual([["Book 1", null], ["Zine", "rare"]]);
|
||||||
|
const rec = await db.k.selectFrom("source_records").selectAll().where("key", "=", "l1").executeTakeFirstOrThrow();
|
||||||
|
expect([rec.target_entity, rec.target_id]).toEqual(["list", list.id]);
|
||||||
|
|
||||||
|
// Source update, list never touched by the user: name and items applied. The ID-less item reuses its draft.
|
||||||
|
src.records = [listRec("l1", "Top", [{ work: draftHint, note: "rare" }, { work: book("2") }])];
|
||||||
|
await runSync(deps(db), plugin, connectionId, "full");
|
||||||
|
expect((await db.k.selectFrom("lists").select("name").where("id", "=", list.id).executeTakeFirstOrThrow()).name).toBe("Top");
|
||||||
|
expect(await itemsOf()).toEqual([["Zine", "rare"], ["Book 2", null]]);
|
||||||
|
expect(await db.k.selectFrom("works").select("id").where("owner_user_id", "=", userId).execute()).toHaveLength(1);
|
||||||
|
const applied = await events(db, { type: "changed", status: "auto_applied" });
|
||||||
|
expect(applied.map((e) => e.payload.field).sort()).toEqual(["items", "name"]);
|
||||||
|
|
||||||
|
// The user edits the list: further source changes become pending.
|
||||||
|
await updateList(db, userId, list.id, { name: "Mine" }, "user");
|
||||||
|
src.records = [listRec("l1", "Top 2", [{ work: book("3") }])];
|
||||||
|
const s3 = await runSync(deps(db), plugin, connectionId, "full");
|
||||||
|
expect(s3.counts.pending).toBe(2);
|
||||||
|
expect((await db.k.selectFrom("lists").select("name").where("id", "=", list.id).executeTakeFirstOrThrow()).name).toBe("Mine");
|
||||||
|
expect(await itemsOf()).toEqual([["Zine", "rare"], ["Book 2", null]]);
|
||||||
|
const pending = await events(db, { type: "changed", status: "pending" });
|
||||||
|
expect(pending.map((e) => e.payload.field).sort()).toEqual(["items", "name"]);
|
||||||
|
const itemsEv = pending.find((e) => e.payload.field === "items")!;
|
||||||
|
expect((itemsEv.payload.to as unknown[]).length).toBe(1);
|
||||||
|
await resolveChange(db, userId, itemsEv.id, "accept");
|
||||||
|
expect(await itemsOf()).toEqual([["Book 3", null]]);
|
||||||
|
const hist = await db.k.selectFrom("user_history").select("actor").where("entity_id", "=", list.id).orderBy("created_at", "desc").orderBy("id", "desc").executeTakeFirstOrThrow();
|
||||||
|
expect(hist.actor).toBe(actor);
|
||||||
|
|
||||||
|
// Removed → pending; accept soft-deletes the list.
|
||||||
|
src.records = [];
|
||||||
|
await runSync(deps(db), plugin, connectionId, "full");
|
||||||
|
const [removed] = await events(db, { type: "removed", status: "pending" });
|
||||||
|
expect(removed!.target_entity).toBe("list");
|
||||||
|
expect((await db.k.selectFrom("lists").select("deleted_at").where("id", "=", list.id).executeTakeFirstOrThrow()).deleted_at).toBeNull();
|
||||||
|
await resolveChange(db, userId, removed!.id, "accept");
|
||||||
|
expect((await db.k.selectFrom("lists").select("deleted_at").where("id", "=", list.id).executeTakeFirstOrThrow()).deleted_at).not.toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("resolveChange is scoped to the user", async () => {
|
||||||
|
const { db, userId, connectionId, src, plugin } = await setup();
|
||||||
|
src.records = [entryRec("a", book("1"), { status: "completed" })];
|
||||||
|
await runSync(deps(db), plugin, connectionId, "full");
|
||||||
|
src.records = [];
|
||||||
|
await runSync(deps(db), plugin, connectionId, "full");
|
||||||
|
const [ev] = await events(db, { type: "removed" });
|
||||||
|
const other = await makeUser(db, "bob");
|
||||||
|
await expect(resolveChange(db, other, ev!.id, "accept")).rejects.toBeInstanceOf(NotFoundError);
|
||||||
|
expect((await listChanges(db, other, { status: "all" })).items).toEqual([]);
|
||||||
|
expect((await listChanges(db, userId, { status: "all" })).items).toHaveLength(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("listChanges pages newest first and filters by connection", async () => {
|
||||||
|
const { db, userId, connectionId, src, plugin } = await setup();
|
||||||
|
src.records = [1, 2, 3].map((i) => entryRec(`k${i}`, book(String(i)), { status: "planned" }));
|
||||||
|
await runSync(deps(db), plugin, connectionId, "full");
|
||||||
|
const p1 = await listChanges(db, userId, { status: "all", limit: 2 });
|
||||||
|
expect(p1.items).toHaveLength(2);
|
||||||
|
expect(p1.nextCursor).not.toBeNull();
|
||||||
|
const p2 = await listChanges(db, userId, { status: "all", limit: 2, cursor: p1.nextCursor! });
|
||||||
|
expect(p2.items).toHaveLength(1);
|
||||||
|
expect(p2.nextCursor).toBeNull();
|
||||||
|
const ids = [...p1.items, ...p2.items].map((c) => c.id);
|
||||||
|
expect(ids).toEqual([...ids].sort().reverse());
|
||||||
|
expect(p1.items[0]!.payload).toMatchObject({ key: "k3" });
|
||||||
|
expect((await listChanges(db, userId, { status: "all", connectionId: "nope" })).items).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("a plugin that does not match the connection is refused", async () => {
|
||||||
|
const { db, connectionId } = await setup("alpha");
|
||||||
|
await expect(runSync(deps(db), fakePlugin("beta", { records: [] }), connectionId, "full")).rejects.toBeInstanceOf(ValidationError);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
import { expect, test } from "bun:test";
|
||||||
|
import type { SourceRecord } from "@sttracker/plugin-sdk/types";
|
||||||
|
import { decodeVersion, encodeVersion, hashRecord, normalizeRecord } from "../../src/sync/snapshot.ts";
|
||||||
|
|
||||||
|
const rec = (raw: unknown, entry: Record<string, unknown>): SourceRecord =>
|
||||||
|
({ key: "k", kind: "entry", work: { title: "T", mediaType: "book", externalIds: { a: "1", b: "2" } }, entry, raw }) as SourceRecord;
|
||||||
|
|
||||||
|
test("hash ignores raw payload and key order", async () => {
|
||||||
|
const a = rec({ x: 1 }, { rating: 1, status: "planned" });
|
||||||
|
const b = rec({ y: 2 }, { status: "planned", rating: 1 });
|
||||||
|
expect(await hashRecord(a)).toBe(await hashRecord(b));
|
||||||
|
expect(await hashRecord(a)).toMatch(/^[0-9a-f]{64}$/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("hash covers normalised fields, deleted and mergedIntoKey", async () => {
|
||||||
|
const base = rec({}, { rating: 1 });
|
||||||
|
const h = await hashRecord(base);
|
||||||
|
expect(await hashRecord(rec({}, { rating: 2 }))).not.toBe(h);
|
||||||
|
expect(await hashRecord({ ...base, deleted: true })).not.toBe(h);
|
||||||
|
expect(await hashRecord({ ...base, mergedIntoKey: "z" })).not.toBe(h);
|
||||||
|
expect(await hashRecord({ ...base, deleted: false })).toBe(h);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("versions round-trip the normalised record and the raw payload", () => {
|
||||||
|
const r = rec({ deep: { list: [1, 2] } }, { rating: 5 });
|
||||||
|
const encoded = encodeVersion(r);
|
||||||
|
expect(typeof encoded).toBe("string");
|
||||||
|
const v = decodeVersion(encoded);
|
||||||
|
expect(v.raw).toEqual({ deep: { list: [1, 2] } });
|
||||||
|
expect(v.normalized).toEqual(normalizeRecord(r));
|
||||||
|
});
|
||||||
@@ -0,0 +1,65 @@
|
|||||||
|
import type { Db } from "@sttracker/db/db";
|
||||||
|
import { newId, nowIso } from "@sttracker/db/ids";
|
||||||
|
import { toJson } from "@sttracker/db/json";
|
||||||
|
|
||||||
|
/** Insert a user and return its id. */
|
||||||
|
export async function makeUser(db: Db, username = "ada", preferences: Record<string, unknown> = {}): Promise<string> {
|
||||||
|
const id = newId();
|
||||||
|
const now = nowIso();
|
||||||
|
await db.k
|
||||||
|
.insertInto("users")
|
||||||
|
.values({
|
||||||
|
id,
|
||||||
|
username,
|
||||||
|
display_name: username,
|
||||||
|
password_hash: "x",
|
||||||
|
role: "member",
|
||||||
|
preferences: toJson(preferences),
|
||||||
|
created_at: now,
|
||||||
|
updated_at: now,
|
||||||
|
})
|
||||||
|
.execute();
|
||||||
|
return id;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Insert a work (shared unless `ownerUserId` is set) and return its id. */
|
||||||
|
export async function makeWork(
|
||||||
|
db: Db,
|
||||||
|
opts: {
|
||||||
|
title?: string;
|
||||||
|
mediaType?: string;
|
||||||
|
year?: number | null;
|
||||||
|
parentId?: string | null;
|
||||||
|
ownerUserId?: string | null;
|
||||||
|
externalIds?: Record<string, string>;
|
||||||
|
} = {},
|
||||||
|
): Promise<string> {
|
||||||
|
const id = newId();
|
||||||
|
const now = nowIso();
|
||||||
|
await db.k
|
||||||
|
.insertInto("works")
|
||||||
|
.values({
|
||||||
|
id,
|
||||||
|
media_type: opts.mediaType ?? "book",
|
||||||
|
title: opts.title ?? "Dune",
|
||||||
|
original_title: "",
|
||||||
|
year: opts.year ?? null,
|
||||||
|
release_date: null,
|
||||||
|
creators: toJson([]),
|
||||||
|
description: "",
|
||||||
|
cover_url: "",
|
||||||
|
cover_attachment_id: null,
|
||||||
|
metadata: toJson({}),
|
||||||
|
parent_id: opts.parentId ?? null,
|
||||||
|
position: null,
|
||||||
|
owner_user_id: opts.ownerUserId ?? null,
|
||||||
|
merged_into_id: null,
|
||||||
|
created_at: now,
|
||||||
|
updated_at: now,
|
||||||
|
})
|
||||||
|
.execute();
|
||||||
|
for (const [namespace, value] of Object.entries(opts.externalIds ?? {})) {
|
||||||
|
await db.k.insertInto("external_ids").values({ id: newId(), work_id: id, namespace, value }).execute();
|
||||||
|
}
|
||||||
|
return id;
|
||||||
|
}
|
||||||
@@ -0,0 +1,196 @@
|
|||||||
|
import { expect, test } from "bun:test";
|
||||||
|
import type { Db } from "@sttracker/db/db";
|
||||||
|
import { newId, nowIso } from "@sttracker/db/ids";
|
||||||
|
import { fromJson } from "@sttracker/db/json";
|
||||||
|
import { forEachDialect } from "@sttracker/db/testing";
|
||||||
|
import { AuthError, ValidationError } from "../src/errors.ts";
|
||||||
|
import { matchWork } from "../src/matching.ts";
|
||||||
|
import {
|
||||||
|
adminMergeWorks,
|
||||||
|
createWork,
|
||||||
|
findWorkByExternalIds,
|
||||||
|
getWork,
|
||||||
|
resolveAlias,
|
||||||
|
searchWorks,
|
||||||
|
updateWorkFromHint,
|
||||||
|
} from "../src/works.ts";
|
||||||
|
|
||||||
|
async function addUser(db: Db, role: "admin" | "member" = "member"): Promise<string> {
|
||||||
|
const id = newId();
|
||||||
|
const now = nowIso();
|
||||||
|
await db.k
|
||||||
|
.insertInto("users")
|
||||||
|
.values({ id, username: `u${id}`, display_name: "U", password_hash: "x", role, preferences: "{}", created_at: now, updated_at: now })
|
||||||
|
.execute();
|
||||||
|
return id;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function addEntry(db: Db, userId: string, workId: string): Promise<string> {
|
||||||
|
const id = newId();
|
||||||
|
const now = nowIso();
|
||||||
|
await db.k
|
||||||
|
.insertInto("entries")
|
||||||
|
.values({
|
||||||
|
id, user_id: userId, work_id: workId, status: "none", rating: null, review: null, private_notes: null,
|
||||||
|
favorite: 0, tags: "[]", custom_fields: "{}", visibility: "private", overrides: "{}",
|
||||||
|
media_type_override: null, field_sources: "{}", created_at: now, updated_at: now, deleted_at: null,
|
||||||
|
})
|
||||||
|
.execute();
|
||||||
|
return id;
|
||||||
|
}
|
||||||
|
|
||||||
|
forEachDialect("works", (getDb) => {
|
||||||
|
test("createWork stores external ids; findWorkByExternalIds finds any namespace", async () => {
|
||||||
|
const db = getDb();
|
||||||
|
const id = await createWork(db, { hint: { title: "Dune", mediaType: "book", year: 1965, externalIds: { isbn13: "9780441013593", hardcover_book: "7" } } }, "test");
|
||||||
|
expect(await findWorkByExternalIds(db, { nope: "1", hardcover_book: "7" })).toBe(id);
|
||||||
|
expect(await findWorkByExternalIds(db, { isbn13: "0" })).toBeNull();
|
||||||
|
expect((await getWork(db, id))?.year).toBe(1965);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("updateWorkFromHint updates shared work, logs history, never touches private", async () => {
|
||||||
|
const db = getDb();
|
||||||
|
const u = await addUser(db);
|
||||||
|
const shared = await createWork(db, { hint: { title: "Dune", mediaType: "book", externalIds: { a: "1" } } }, "t");
|
||||||
|
await updateWorkFromHint(db, shared, { title: "Dune", mediaType: "book", year: 1965, description: "Sand", externalIds: { a: "1", b: "2" } }, "plug");
|
||||||
|
const w = await getWork(db, shared);
|
||||||
|
expect(w?.year).toBe(1965);
|
||||||
|
expect(w?.description).toBe("Sand");
|
||||||
|
expect(await findWorkByExternalIds(db, { b: "2" })).toBe(shared);
|
||||||
|
const hist = await db.k.selectFrom("work_history").selectAll().where("work_id", "=", shared).where("actor", "=", "plug").execute();
|
||||||
|
expect(hist).toHaveLength(1);
|
||||||
|
expect(fromJson<Record<string, unknown>>(hist[0]!.before, {}).year).toBeNull();
|
||||||
|
|
||||||
|
const priv = await createWork(db, { hint: { title: "Mine", mediaType: "book", externalIds: {} }, ownerUserId: u }, "t");
|
||||||
|
await updateWorkFromHint(db, priv, { title: "Changed", mediaType: "book", externalIds: {} }, "plug");
|
||||||
|
expect((await getWork(db, priv))?.title).toBe("Mine");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("private works invisible to other users in search", async () => {
|
||||||
|
const db = getDb();
|
||||||
|
const u1 = await addUser(db);
|
||||||
|
const u2 = await addUser(db);
|
||||||
|
await createWork(db, { hint: { title: "Secret 100%", mediaType: "book", externalIds: {} }, ownerUserId: u1 }, "t");
|
||||||
|
await createWork(db, { hint: { title: "Public Thing", mediaType: "book", externalIds: { x: "1" } } }, "t");
|
||||||
|
expect((await searchWorks(db, u1, "secret 100%")).map((w) => w.title)).toEqual(["Secret 100%"]);
|
||||||
|
expect(await searchWorks(db, u2, "secret")).toEqual([]);
|
||||||
|
expect((await searchWorks(db, u2, "PUBLIC")).map((w) => w.title)).toEqual(["Public Thing"]);
|
||||||
|
expect(await searchWorks(db, u2, "public", { mediaType: "game" })).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("adminMergeWorks requires admin", async () => {
|
||||||
|
const db = getDb();
|
||||||
|
const member = await addUser(db);
|
||||||
|
const a = await createWork(db, { hint: { title: "A", mediaType: "book", externalIds: { x: "1" } } }, "t");
|
||||||
|
const b = await createWork(db, { hint: { title: "B", mediaType: "book", externalIds: { x: "2" } } }, "t");
|
||||||
|
await expect(adminMergeWorks(db, member, a, b)).rejects.toBeInstanceOf(AuthError);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("adminMergeWorks tombstones, moves ids, repoints entries, emits merged event on conflict", async () => {
|
||||||
|
const db = getDb();
|
||||||
|
const admin = await addUser(db, "admin");
|
||||||
|
const u1 = await addUser(db);
|
||||||
|
const u2 = await addUser(db);
|
||||||
|
const from = await createWork(db, { hint: { title: "Old", mediaType: "book", externalIds: { x: "1" } } }, "t");
|
||||||
|
const to = await createWork(db, { hint: { title: "New", mediaType: "book", externalIds: { x: "2" } } }, "t");
|
||||||
|
const e1 = await addEntry(db, u1, from);
|
||||||
|
const e2from = await addEntry(db, u2, from);
|
||||||
|
await addEntry(db, u2, to);
|
||||||
|
|
||||||
|
await adminMergeWorks(db, admin, from, to);
|
||||||
|
|
||||||
|
expect((await getWork(db, from))?.id).toBe(to);
|
||||||
|
expect(await findWorkByExternalIds(db, { x: "1" })).toBe(to);
|
||||||
|
expect(await resolveAlias(db, u1, from)).toBe(to);
|
||||||
|
expect((await db.k.selectFrom("entries").select("work_id").where("id", "=", e1).executeTakeFirstOrThrow()).work_id).toBe(to);
|
||||||
|
expect((await db.k.selectFrom("entries").select("work_id").where("id", "=", e2from).executeTakeFirstOrThrow()).work_id).toBe(from);
|
||||||
|
const ev = await db.k.selectFrom("change_events").selectAll().execute();
|
||||||
|
expect(ev).toHaveLength(1);
|
||||||
|
expect(ev[0]).toMatchObject({ user_id: u2, type: "merged", status: "pending", target_entity: "entry", target_id: e2from });
|
||||||
|
expect(fromJson(ev[0]!.payload, {})).toEqual({ fromWorkId: from, toWorkId: to });
|
||||||
|
expect(await db.k.selectFrom("work_history").select("id").where("work_id", "=", from).where("actor", "=", admin).execute()).toHaveLength(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("createWork refuses external ids on a private work", async () => {
|
||||||
|
const db = getDb();
|
||||||
|
const u = await addUser(db);
|
||||||
|
const p = createWork(db, { hint: { title: "Mine", mediaType: "book", externalIds: { isbn13: "1" } }, ownerUserId: u }, "t");
|
||||||
|
await expect(p).rejects.toBeInstanceOf(ValidationError);
|
||||||
|
await expect(p).rejects.toMatchObject({ field: "externalIds", reason: "private works cannot carry external ids" });
|
||||||
|
expect(await db.k.selectFrom("works").select("id").execute()).toHaveLength(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("findWorkByExternalIds only returns works visible to the user", async () => {
|
||||||
|
const db = getDb();
|
||||||
|
const a = await addUser(db);
|
||||||
|
const b = await addUser(db);
|
||||||
|
const priv = await createWork(db, { hint: { title: "Mine", mediaType: "book", externalIds: {} }, ownerUserId: a }, "t");
|
||||||
|
// Legacy data: a private work carrying an external id (no longer creatable through createWork).
|
||||||
|
await db.k.insertInto("external_ids").values({ id: newId(), work_id: priv, namespace: "isbn13", value: "42" }).execute();
|
||||||
|
expect(await findWorkByExternalIds(db, { isbn13: "42" }, a)).toBe(priv);
|
||||||
|
expect(await findWorkByExternalIds(db, { isbn13: "42" }, b)).toBeNull();
|
||||||
|
expect(await findWorkByExternalIds(db, { isbn13: "42" })).toBeNull();
|
||||||
|
const shared = await createWork(db, { hint: { title: "Theirs", mediaType: "book", externalIds: { asin: "X" } } }, "t");
|
||||||
|
const hint = { title: "Mine", mediaType: "book" as const, externalIds: { isbn13: "42", asin: "X" } };
|
||||||
|
expect(await matchWork(db, b, hint, "p")).toEqual({ workId: shared, created: false, draft: false });
|
||||||
|
expect(await matchWork(db, a, hint, "p")).toEqual({ workId: priv, created: false, draft: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
test("getWork returns null on a merged_into cycle", async () => {
|
||||||
|
const db = getDb();
|
||||||
|
const a = await createWork(db, { hint: { title: "A", mediaType: "book", externalIds: { x: "1" } } }, "t");
|
||||||
|
const b = await createWork(db, { hint: { title: "B", mediaType: "book", externalIds: { x: "2" } } }, "t");
|
||||||
|
await db.k.updateTable("works").set({ merged_into_id: b }).where("id", "=", a).execute();
|
||||||
|
await db.k.updateTable("works").set({ merged_into_id: a }).where("id", "=", b).execute();
|
||||||
|
expect(await getWork(db, a)).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("search treats a literal _ as a character, not a wildcard", async () => {
|
||||||
|
const db = getDb();
|
||||||
|
const u = await addUser(db);
|
||||||
|
await createWork(db, { hint: { title: "snake_case", mediaType: "book", externalIds: { x: "1" } } }, "t");
|
||||||
|
await createWork(db, { hint: { title: "snakeXcase", mediaType: "book", externalIds: { x: "2" } } }, "t");
|
||||||
|
expect((await searchWorks(db, u, "e_c")).map((w) => w.title)).toEqual(["snake_case"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("adminMergeWorks refuses private works", async () => {
|
||||||
|
const db = getDb();
|
||||||
|
const admin = await addUser(db, "admin");
|
||||||
|
const shared = await createWork(db, { hint: { title: "A", mediaType: "book", externalIds: { x: "1" } } }, "t");
|
||||||
|
const priv = await createWork(db, { hint: { title: "B", mediaType: "book", externalIds: {} }, ownerUserId: admin }, "t");
|
||||||
|
await expect(adminMergeWorks(db, admin, priv, shared)).rejects.toBeInstanceOf(ValidationError);
|
||||||
|
await expect(adminMergeWorks(db, admin, shared, priv)).rejects.toBeInstanceOf(ValidationError);
|
||||||
|
expect((await getWork(db, priv))?.id).toBe(priv);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("adminMergeWorks moves children that do not collide, so matching does not recreate them", async () => {
|
||||||
|
const db = getDb();
|
||||||
|
const admin = await addUser(db, "admin");
|
||||||
|
const u = await addUser(db);
|
||||||
|
const show = (id: string) => ({ title: "Lost", mediaType: "show" as const, externalIds: { tmdb_show: id } });
|
||||||
|
const ep = (showId: string, season: number) => ({
|
||||||
|
title: "Pilot",
|
||||||
|
mediaType: "episode" as const,
|
||||||
|
externalIds: {},
|
||||||
|
position: { season, episode: 1 },
|
||||||
|
parent: { title: `Season ${season}`, mediaType: "season" as const, externalIds: {}, position: { season }, parent: show(showId) },
|
||||||
|
});
|
||||||
|
const a1 = await matchWork(db, u, ep("A", 1), "p");
|
||||||
|
const a2 = await matchWork(db, u, ep("A", 2), "p");
|
||||||
|
const b2 = await matchWork(db, u, ep("B", 2), "p");
|
||||||
|
const showA = (await findWorkByExternalIds(db, { tmdb_show: "A" }))!;
|
||||||
|
const showB = (await findWorkByExternalIds(db, { tmdb_show: "B" }))!;
|
||||||
|
const seasonA1 = (await getWork(db, a1.workId))!.parent_id!;
|
||||||
|
const seasonA2 = (await getWork(db, a2.workId))!.parent_id!;
|
||||||
|
const before = (await db.k.selectFrom("works").select("id").execute()).length;
|
||||||
|
|
||||||
|
await adminMergeWorks(db, admin, showA, showB);
|
||||||
|
|
||||||
|
expect((await getWork(db, seasonA1))?.parent_id).toBe(showB); // moved: B had no season 1
|
||||||
|
expect((await getWork(db, seasonA2))?.parent_id).toBe(showA); // left: B already had season 2
|
||||||
|
const again = await matchWork(db, u, ep("A", 1), "p");
|
||||||
|
expect(again).toEqual({ workId: a1.workId, created: false, draft: false });
|
||||||
|
expect((await matchWork(db, u, ep("A", 2), "p")).workId).toBe(b2.workId);
|
||||||
|
expect((await db.k.selectFrom("works").select("id").execute()).length).toBe(before);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
{
|
||||||
|
"name": "@sttracker/db",
|
||||||
|
"private": true,
|
||||||
|
"type": "module",
|
||||||
|
"exports": {
|
||||||
|
"./*": "./src/*.ts"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"kysely": "^0.29.7"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
import { CompiledQuery, Kysely } from "kysely";
|
||||||
|
import { BunSqlDialect, type Dialect } from "./dialect.ts";
|
||||||
|
import type { Database } from "./schema.ts";
|
||||||
|
|
||||||
|
export interface Db {
|
||||||
|
k: Kysely<Database>;
|
||||||
|
dialect: Dialect;
|
||||||
|
close(): Promise<void>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Open a database from `sqlite://<path>`, `sqlite://:memory:` or `postgres://…`. */
|
||||||
|
export async function createDb(url: string): Promise<Db> {
|
||||||
|
const d = new BunSqlDialect(url);
|
||||||
|
const k = new Kysely<Database>({ dialect: d });
|
||||||
|
try {
|
||||||
|
if (d.dialect === "sqlite") {
|
||||||
|
await k.executeQuery(CompiledQuery.raw("PRAGMA foreign_keys = ON"));
|
||||||
|
if (!url.includes(":memory:")) await k.executeQuery(CompiledQuery.raw("PRAGMA journal_mode = WAL"));
|
||||||
|
} else {
|
||||||
|
// Fail fast on a bad URL or unreachable server instead of at first query.
|
||||||
|
await k.executeQuery(CompiledQuery.raw("select 1"));
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
await k.destroy();
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
return { k, dialect: d.dialect, close: () => k.destroy() };
|
||||||
|
}
|
||||||
@@ -0,0 +1,160 @@
|
|||||||
|
import { SQL, type ReservedSQL } from "bun";
|
||||||
|
import {
|
||||||
|
CompiledQuery,
|
||||||
|
PostgresAdapter,
|
||||||
|
PostgresIntrospector,
|
||||||
|
PostgresQueryCompiler,
|
||||||
|
SqliteAdapter,
|
||||||
|
SqliteIntrospector,
|
||||||
|
SqliteQueryCompiler,
|
||||||
|
type DatabaseConnection,
|
||||||
|
type DatabaseIntrospector,
|
||||||
|
type DialectAdapter,
|
||||||
|
type Dialect as KyselyDialect,
|
||||||
|
type Driver,
|
||||||
|
type Kysely,
|
||||||
|
type QueryCompiler,
|
||||||
|
type QueryResult,
|
||||||
|
type TransactionSettings,
|
||||||
|
} from "kysely";
|
||||||
|
|
||||||
|
export type Dialect = "sqlite" | "postgres";
|
||||||
|
|
||||||
|
export function dialectOf(url: string): Dialect {
|
||||||
|
if (url.startsWith("sqlite://")) return "sqlite";
|
||||||
|
if (url.startsWith("postgres://") || url.startsWith("postgresql://")) return "postgres";
|
||||||
|
throw new Error(`Unsupported database URL scheme: ${url.split(":")[0]}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Only these commands report a meaningful affected-row count. */
|
||||||
|
const WRITE_COMMANDS = new Set(["INSERT", "UPDATE", "DELETE", "MERGE"]);
|
||||||
|
|
||||||
|
type Runner = Pick<SQL, "unsafe">;
|
||||||
|
|
||||||
|
class BunSqlConnection implements DatabaseConnection {
|
||||||
|
constructor(
|
||||||
|
readonly runner: Runner,
|
||||||
|
private readonly dialect: Dialect,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
async executeQuery<R>(compiled: CompiledQuery): Promise<QueryResult<R>> {
|
||||||
|
const result = await this.runner.unsafe(compiled.sql, compiled.parameters as unknown[]);
|
||||||
|
const rows = [...result] as R[];
|
||||||
|
const command: string | undefined = result.command;
|
||||||
|
if (!command || !WRITE_COMMANDS.has(command)) return { rows };
|
||||||
|
const numAffectedRows = BigInt(result.count ?? 0);
|
||||||
|
const lastId = result.lastInsertRowid;
|
||||||
|
if (this.dialect === "sqlite" && command === "INSERT" && lastId != null) {
|
||||||
|
return { rows, numAffectedRows, insertId: BigInt(lastId) };
|
||||||
|
}
|
||||||
|
return { rows, numAffectedRows };
|
||||||
|
}
|
||||||
|
|
||||||
|
async *streamQuery<R>(compiled: CompiledQuery): AsyncIterableIterator<QueryResult<R>> {
|
||||||
|
yield await this.executeQuery<R>(compiled);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Serialises access to the single SQLite connection. Bun.SQL's SQLite adapter
|
||||||
|
* does not support `reserve()`, so a held connection is the whole database.
|
||||||
|
*/
|
||||||
|
class Mutex {
|
||||||
|
private tail: Promise<void> = Promise.resolve();
|
||||||
|
|
||||||
|
async lock(): Promise<() => void> {
|
||||||
|
let unlock!: () => void;
|
||||||
|
const next = new Promise<void>((resolve) => (unlock = resolve));
|
||||||
|
const prev = this.tail;
|
||||||
|
this.tail = prev.then(() => next);
|
||||||
|
await prev;
|
||||||
|
return unlock;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class BunSqlDriver implements Driver {
|
||||||
|
private sql: SQL | undefined;
|
||||||
|
private readonly sqliteLock = new Mutex();
|
||||||
|
private readonly unlocks = new WeakMap<DatabaseConnection, () => void>();
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
private readonly url: string,
|
||||||
|
private readonly dialect: Dialect,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
async init(): Promise<void> {
|
||||||
|
this.sql = new SQL(this.url);
|
||||||
|
}
|
||||||
|
|
||||||
|
private get client(): SQL {
|
||||||
|
if (!this.sql) throw new Error("Database driver is not initialised or already destroyed");
|
||||||
|
return this.sql;
|
||||||
|
}
|
||||||
|
|
||||||
|
async acquireConnection(): Promise<DatabaseConnection> {
|
||||||
|
if (this.dialect === "postgres") {
|
||||||
|
return new BunSqlConnection(await this.client.reserve(), this.dialect);
|
||||||
|
}
|
||||||
|
const unlock = await this.sqliteLock.lock();
|
||||||
|
const conn = new BunSqlConnection(this.client, this.dialect);
|
||||||
|
this.unlocks.set(conn, unlock);
|
||||||
|
return conn;
|
||||||
|
}
|
||||||
|
|
||||||
|
async releaseConnection(connection: DatabaseConnection): Promise<void> {
|
||||||
|
if (this.dialect === "postgres") {
|
||||||
|
((connection as BunSqlConnection).runner as ReservedSQL).release();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.unlocks.get(connection)?.();
|
||||||
|
this.unlocks.delete(connection);
|
||||||
|
}
|
||||||
|
|
||||||
|
async beginTransaction(connection: DatabaseConnection, settings: TransactionSettings): Promise<void> {
|
||||||
|
let stmt = "begin";
|
||||||
|
if (this.dialect === "postgres") {
|
||||||
|
if (settings.isolationLevel) stmt += ` isolation level ${settings.isolationLevel}`;
|
||||||
|
if (settings.accessMode) stmt += ` ${settings.accessMode}`;
|
||||||
|
}
|
||||||
|
await connection.executeQuery(CompiledQuery.raw(stmt));
|
||||||
|
}
|
||||||
|
|
||||||
|
async commitTransaction(connection: DatabaseConnection): Promise<void> {
|
||||||
|
await connection.executeQuery(CompiledQuery.raw("commit"));
|
||||||
|
}
|
||||||
|
|
||||||
|
async rollbackTransaction(connection: DatabaseConnection): Promise<void> {
|
||||||
|
await connection.executeQuery(CompiledQuery.raw("rollback"));
|
||||||
|
}
|
||||||
|
|
||||||
|
async destroy(): Promise<void> {
|
||||||
|
const sql = this.sql;
|
||||||
|
this.sql = undefined;
|
||||||
|
await sql?.close();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** One Kysely dialect over Bun.SQL for both `sqlite://` and `postgres://` URLs. */
|
||||||
|
export class BunSqlDialect implements KyselyDialect {
|
||||||
|
readonly dialect: Dialect;
|
||||||
|
|
||||||
|
constructor(private readonly url: string) {
|
||||||
|
this.dialect = dialectOf(url);
|
||||||
|
}
|
||||||
|
|
||||||
|
createDriver(): Driver {
|
||||||
|
return new BunSqlDriver(this.url, this.dialect);
|
||||||
|
}
|
||||||
|
|
||||||
|
createQueryCompiler(): QueryCompiler {
|
||||||
|
return this.dialect === "sqlite" ? new SqliteQueryCompiler() : new PostgresQueryCompiler();
|
||||||
|
}
|
||||||
|
|
||||||
|
createAdapter(): DialectAdapter {
|
||||||
|
return this.dialect === "sqlite" ? new SqliteAdapter() : new PostgresAdapter();
|
||||||
|
}
|
||||||
|
|
||||||
|
createIntrospector(db: Kysely<any>): DatabaseIntrospector {
|
||||||
|
return this.dialect === "sqlite" ? new SqliteIntrospector(db) : new PostgresIntrospector(db);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
/** Time-sortable UUIDv7, generated in app code. */
|
||||||
|
export function newId(): string {
|
||||||
|
return Bun.randomUUIDv7();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Current time as an ISO-8601 UTC string, the storage format for timestamps. */
|
||||||
|
export function nowIso(): string {
|
||||||
|
return new Date().toISOString();
|
||||||
|
}
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
/** Serialise a value for a JSON column (stored as text on both dialects). */
|
||||||
|
export function toJson(v: unknown): string {
|
||||||
|
return JSON.stringify(v);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Parse a JSON column, returning `fallback` for null, undefined or invalid text. */
|
||||||
|
export function fromJson<T>(s: string | null | undefined, fallback: T): T {
|
||||||
|
if (s == null) return fallback;
|
||||||
|
try {
|
||||||
|
return JSON.parse(s) as T;
|
||||||
|
} catch {
|
||||||
|
return fallback;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
import type { Kysely } from "kysely";
|
||||||
|
import { Migrator, type Migration, type MigrationProvider } from "kysely/migration";
|
||||||
|
import type { Db } from "./db.ts";
|
||||||
|
import { migrations } from "./migrations/index.ts";
|
||||||
|
|
||||||
|
class StaticMigrationProvider implements MigrationProvider {
|
||||||
|
constructor(private readonly all: Record<string, Migration>) {}
|
||||||
|
async getMigrations(): Promise<Record<string, Migration>> {
|
||||||
|
return this.all;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Apply every pending migration; throws on the first failure. A no-op when there are none. */
|
||||||
|
export async function migrateToLatest(db: Db): Promise<void> {
|
||||||
|
const migrator = new Migrator({
|
||||||
|
db: db.k as Kysely<any>,
|
||||||
|
provider: new StaticMigrationProvider(migrations),
|
||||||
|
});
|
||||||
|
const { error, results } = await migrator.migrateToLatest();
|
||||||
|
if (error) {
|
||||||
|
const failed = results?.find((r) => r.status === "Error")?.migrationName;
|
||||||
|
throw new Error(`Migration ${failed ?? "(unknown)"} failed: ${String(error)}`, { cause: error });
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,425 @@
|
|||||||
|
import type { Kysely } from "kysely";
|
||||||
|
import type { Migration } from "kysely/migration";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Column kinds: `t` text, `i` integer, `r` real, `fk:<table>` text reference. Not null by default;
|
||||||
|
* a trailing `?` makes it nullable. Only portable types, so the same code runs on SQLite and Postgres.
|
||||||
|
*/
|
||||||
|
type Kind = string;
|
||||||
|
type Spec = Array<[name: string, kind: Kind]>;
|
||||||
|
|
||||||
|
async function create(
|
||||||
|
db: Kysely<any>,
|
||||||
|
name: string,
|
||||||
|
cols: Spec,
|
||||||
|
opts: { unique?: string[][]; indexes?: string[][] } = {},
|
||||||
|
): Promise<void> {
|
||||||
|
let b = db.schema.createTable(name).addColumn("id", "text", (c) => c.primaryKey());
|
||||||
|
const fkCols: string[] = [];
|
||||||
|
for (const [col, kind] of cols) {
|
||||||
|
const nullable = kind.endsWith("?");
|
||||||
|
const base = nullable ? kind.slice(0, -1) : kind;
|
||||||
|
const isFk = base.startsWith("fk:");
|
||||||
|
const type = base === "i" ? "integer" : base === "r" ? "real" : "text";
|
||||||
|
if (isFk) fkCols.push(col);
|
||||||
|
b = b.addColumn(col, type, (c) => {
|
||||||
|
let x = c;
|
||||||
|
if (isFk) x = x.references(`${base.slice(3)}.id`);
|
||||||
|
return nullable ? x : x.notNull();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
for (const u of opts.unique ?? []) b = b.addUniqueConstraint(`${name}_${u.join("_")}_uq`, u as Array<"id">);
|
||||||
|
await b.execute();
|
||||||
|
|
||||||
|
const indexes = [...fkCols.map((c) => [c]), ...(opts.indexes ?? [])];
|
||||||
|
for (const cols of indexes) {
|
||||||
|
await db.schema
|
||||||
|
.createIndex(`${name}_${cols.join("_")}_idx`)
|
||||||
|
.on(name)
|
||||||
|
.columns(cols as Array<"id">)
|
||||||
|
.execute();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const initial: Migration = {
|
||||||
|
async up(db: Kysely<any>) {
|
||||||
|
await create(
|
||||||
|
db,
|
||||||
|
"users",
|
||||||
|
[
|
||||||
|
["username", "t"],
|
||||||
|
["display_name", "t"],
|
||||||
|
["password_hash", "t"],
|
||||||
|
["role", "t"],
|
||||||
|
["preferences", "t"],
|
||||||
|
["created_at", "t"],
|
||||||
|
["updated_at", "t"],
|
||||||
|
],
|
||||||
|
{ unique: [["username"]] },
|
||||||
|
);
|
||||||
|
await create(
|
||||||
|
db,
|
||||||
|
"sessions",
|
||||||
|
[
|
||||||
|
["user_id", "fk:users"],
|
||||||
|
["token_hash", "t"],
|
||||||
|
["expires_at", "t"],
|
||||||
|
["created_at", "t"],
|
||||||
|
],
|
||||||
|
{ unique: [["token_hash"]] },
|
||||||
|
);
|
||||||
|
await create(
|
||||||
|
db,
|
||||||
|
"invites",
|
||||||
|
[
|
||||||
|
["code_hash", "t"],
|
||||||
|
["created_by", "fk:users"],
|
||||||
|
["used_by", "fk:users?"],
|
||||||
|
["expires_at", "t"],
|
||||||
|
["created_at", "t"],
|
||||||
|
],
|
||||||
|
{ unique: [["code_hash"]] },
|
||||||
|
);
|
||||||
|
await create(
|
||||||
|
db,
|
||||||
|
"app_tokens",
|
||||||
|
[
|
||||||
|
["user_id", "fk:users"],
|
||||||
|
["name", "t"],
|
||||||
|
["token_hash", "t"],
|
||||||
|
["scopes", "t"],
|
||||||
|
["last_used_at", "t?"],
|
||||||
|
["revoked_at", "t?"],
|
||||||
|
["created_at", "t"],
|
||||||
|
],
|
||||||
|
{ unique: [["token_hash"]] },
|
||||||
|
);
|
||||||
|
// works.parent_id is a self reference; works.cover_attachment_id and merged_into_id stay
|
||||||
|
// plain columns (attachments is created later; merges may point at any work).
|
||||||
|
await create(
|
||||||
|
db,
|
||||||
|
"works",
|
||||||
|
[
|
||||||
|
["media_type", "t"],
|
||||||
|
["title", "t"],
|
||||||
|
["original_title", "t"],
|
||||||
|
["year", "i?"],
|
||||||
|
["release_date", "t?"],
|
||||||
|
["creators", "t"],
|
||||||
|
["description", "t"],
|
||||||
|
["cover_url", "t"],
|
||||||
|
["cover_attachment_id", "t?"],
|
||||||
|
["metadata", "t"],
|
||||||
|
["parent_id", "fk:works?"],
|
||||||
|
["position", "t?"],
|
||||||
|
["owner_user_id", "fk:users?"],
|
||||||
|
["merged_into_id", "t?"],
|
||||||
|
["created_at", "t"],
|
||||||
|
["updated_at", "t"],
|
||||||
|
],
|
||||||
|
{ indexes: [["merged_into_id"], ["media_type"]] },
|
||||||
|
);
|
||||||
|
await create(
|
||||||
|
db,
|
||||||
|
"external_ids",
|
||||||
|
[
|
||||||
|
["work_id", "fk:works"],
|
||||||
|
["namespace", "t"],
|
||||||
|
["value", "t"],
|
||||||
|
],
|
||||||
|
{ unique: [["namespace", "value"]] },
|
||||||
|
);
|
||||||
|
await create(
|
||||||
|
db,
|
||||||
|
"work_aliases",
|
||||||
|
[
|
||||||
|
["user_id", "fk:users"],
|
||||||
|
["from_work_id", "fk:works"],
|
||||||
|
["to_work_id", "fk:works"],
|
||||||
|
],
|
||||||
|
{ unique: [["user_id", "from_work_id"]] },
|
||||||
|
);
|
||||||
|
await create(db, "work_history", [
|
||||||
|
["work_id", "fk:works"],
|
||||||
|
["before", "t"],
|
||||||
|
["after", "t"],
|
||||||
|
["actor", "t"],
|
||||||
|
["created_at", "t"],
|
||||||
|
]);
|
||||||
|
await create(
|
||||||
|
db,
|
||||||
|
"entries",
|
||||||
|
[
|
||||||
|
["user_id", "fk:users"],
|
||||||
|
["work_id", "fk:works"],
|
||||||
|
["status", "t"],
|
||||||
|
["rating", "i?"],
|
||||||
|
["review", "t?"],
|
||||||
|
["private_notes", "t?"],
|
||||||
|
["favorite", "i"],
|
||||||
|
["tags", "t"],
|
||||||
|
["custom_fields", "t"],
|
||||||
|
["visibility", "t"],
|
||||||
|
["overrides", "t"],
|
||||||
|
["media_type_override", "t?"],
|
||||||
|
["field_sources", "t"],
|
||||||
|
["created_at", "t"],
|
||||||
|
["updated_at", "t"],
|
||||||
|
["deleted_at", "t?"],
|
||||||
|
],
|
||||||
|
{ unique: [["user_id", "work_id"]] },
|
||||||
|
);
|
||||||
|
await create(db, "engagements", [
|
||||||
|
["entry_id", "fk:entries"],
|
||||||
|
["started_at", "t?"],
|
||||||
|
["started_precision", "t"],
|
||||||
|
["finished_at", "t?"],
|
||||||
|
["finished_precision", "t"],
|
||||||
|
["progress", "t"],
|
||||||
|
["platform", "t?"],
|
||||||
|
["rating", "i?"],
|
||||||
|
["notes", "t?"],
|
||||||
|
["custom_fields", "t"],
|
||||||
|
["field_sources", "t"],
|
||||||
|
["created_at", "t"],
|
||||||
|
["updated_at", "t"],
|
||||||
|
["deleted_at", "t?"],
|
||||||
|
]);
|
||||||
|
await create(
|
||||||
|
db,
|
||||||
|
"source_connections",
|
||||||
|
[
|
||||||
|
["user_id", "fk:users"],
|
||||||
|
["plugin_id", "t"],
|
||||||
|
["name", "t"],
|
||||||
|
["config", "t"],
|
||||||
|
["credentials_enc", "t?"],
|
||||||
|
["schedule_minutes", "i?"],
|
||||||
|
["cursor", "t?"],
|
||||||
|
["status", "t"],
|
||||||
|
["last_run_at", "t?"],
|
||||||
|
["created_at", "t"],
|
||||||
|
],
|
||||||
|
);
|
||||||
|
await create(
|
||||||
|
db,
|
||||||
|
"engagement_sources",
|
||||||
|
[
|
||||||
|
["engagement_id", "fk:engagements"],
|
||||||
|
["connection_id", "fk:source_connections"],
|
||||||
|
["record_key", "t"],
|
||||||
|
],
|
||||||
|
{ unique: [["connection_id", "record_key"]] },
|
||||||
|
);
|
||||||
|
await create(
|
||||||
|
db,
|
||||||
|
"listens",
|
||||||
|
[
|
||||||
|
["user_id", "fk:users"],
|
||||||
|
["listened_at", "t"],
|
||||||
|
["artist", "t"],
|
||||||
|
["track", "t"],
|
||||||
|
["album", "t?"],
|
||||||
|
["duration_ms", "i?"],
|
||||||
|
["mbids", "t"],
|
||||||
|
["work_id", "fk:works?"],
|
||||||
|
["source", "t"],
|
||||||
|
["source_key", "t"],
|
||||||
|
["created_at", "t"],
|
||||||
|
],
|
||||||
|
{ unique: [["user_id", "source", "source_key"]], indexes: [["user_id", "listened_at"]] },
|
||||||
|
);
|
||||||
|
await create(db, "lists", [
|
||||||
|
["user_id", "fk:users"],
|
||||||
|
["name", "t"],
|
||||||
|
["description", "t"],
|
||||||
|
["kind", "t"],
|
||||||
|
["visibility", "t"],
|
||||||
|
["rating", "i?"],
|
||||||
|
["review", "t?"],
|
||||||
|
["created_at", "t"],
|
||||||
|
["updated_at", "t"],
|
||||||
|
["deleted_at", "t?"],
|
||||||
|
]);
|
||||||
|
await create(db, "list_items", [
|
||||||
|
["list_id", "fk:lists"],
|
||||||
|
["work_id", "fk:works"],
|
||||||
|
["position", "i"],
|
||||||
|
["note", "t?"],
|
||||||
|
]);
|
||||||
|
await create(db, "custom_field_definitions", [
|
||||||
|
["user_id", "fk:users"],
|
||||||
|
["name", "t"],
|
||||||
|
["type", "t"],
|
||||||
|
["options", "t"],
|
||||||
|
["applies_to", "t"],
|
||||||
|
["level", "t"],
|
||||||
|
["created_at", "t"],
|
||||||
|
]);
|
||||||
|
await create(db, "copies", [
|
||||||
|
["user_id", "fk:users"],
|
||||||
|
["work_id", "fk:works?"],
|
||||||
|
["title_hint", "t?"],
|
||||||
|
["format", "t?"],
|
||||||
|
["acquired_from", "t?"],
|
||||||
|
["acquired_at", "t?"],
|
||||||
|
["acquired_precision", "t"],
|
||||||
|
["price_cents", "i?"],
|
||||||
|
["currency", "t?"],
|
||||||
|
["notes", "t?"],
|
||||||
|
["custom_fields", "t"],
|
||||||
|
["source_ref", "t?"],
|
||||||
|
["created_at", "t"],
|
||||||
|
["updated_at", "t"],
|
||||||
|
["deleted_at", "t?"],
|
||||||
|
]);
|
||||||
|
await create(
|
||||||
|
db,
|
||||||
|
"attachments",
|
||||||
|
[
|
||||||
|
["user_id", "fk:users"],
|
||||||
|
["sha256", "t"],
|
||||||
|
["mime", "t"],
|
||||||
|
["size", "i"],
|
||||||
|
["filename", "t"],
|
||||||
|
["caption", "t?"],
|
||||||
|
["taken_at", "t?"],
|
||||||
|
["visibility", "t?"],
|
||||||
|
["created_at", "t"],
|
||||||
|
],
|
||||||
|
{ indexes: [["sha256"]] },
|
||||||
|
);
|
||||||
|
await create(
|
||||||
|
db,
|
||||||
|
"attachment_links",
|
||||||
|
[
|
||||||
|
["attachment_id", "fk:attachments"],
|
||||||
|
["entity", "t"],
|
||||||
|
["entity_id", "t"],
|
||||||
|
],
|
||||||
|
{ indexes: [["entity", "entity_id"]] },
|
||||||
|
);
|
||||||
|
await create(
|
||||||
|
db,
|
||||||
|
"user_history",
|
||||||
|
[
|
||||||
|
["user_id", "fk:users"],
|
||||||
|
["entity", "t"],
|
||||||
|
["entity_id", "t"],
|
||||||
|
["before", "t"],
|
||||||
|
["after", "t"],
|
||||||
|
["actor", "t"],
|
||||||
|
["created_at", "t"],
|
||||||
|
],
|
||||||
|
{ indexes: [["entity", "entity_id"]] },
|
||||||
|
);
|
||||||
|
await create(db, "match_suggestions", [
|
||||||
|
["user_id", "fk:users"],
|
||||||
|
["work_id", "fk:works"],
|
||||||
|
["candidate_work_id", "fk:works"],
|
||||||
|
["score", "r"],
|
||||||
|
["status", "t"],
|
||||||
|
["created_at", "t"],
|
||||||
|
]);
|
||||||
|
await create(db, "source_runs", [
|
||||||
|
["connection_id", "fk:source_connections"],
|
||||||
|
["mode", "t"],
|
||||||
|
["started_at", "t"],
|
||||||
|
["finished_at", "t?"],
|
||||||
|
["status", "t"],
|
||||||
|
["counts", "t"],
|
||||||
|
["log", "t"],
|
||||||
|
["error", "t?"],
|
||||||
|
]);
|
||||||
|
await create(
|
||||||
|
db,
|
||||||
|
"source_records",
|
||||||
|
[
|
||||||
|
["connection_id", "fk:source_connections"],
|
||||||
|
["key", "t"],
|
||||||
|
["kind", "t"],
|
||||||
|
["current_hash", "t"],
|
||||||
|
["first_seen", "t"],
|
||||||
|
["last_seen", "t"],
|
||||||
|
["deleted_at", "t?"],
|
||||||
|
["merged_into_key", "t?"],
|
||||||
|
["target_entity", "t?"],
|
||||||
|
["target_id", "t?"],
|
||||||
|
],
|
||||||
|
{ unique: [["connection_id", "key"]], indexes: [["target_entity", "target_id"]] },
|
||||||
|
);
|
||||||
|
await create(db, "source_record_versions", [
|
||||||
|
["record_id", "fk:source_records"],
|
||||||
|
["hash", "t"],
|
||||||
|
["raw_gz", "t"],
|
||||||
|
["seen_at", "t"],
|
||||||
|
]);
|
||||||
|
await create(
|
||||||
|
db,
|
||||||
|
"change_events",
|
||||||
|
[
|
||||||
|
["user_id", "fk:users"],
|
||||||
|
["connection_id", "fk:source_connections?"],
|
||||||
|
["run_id", "fk:source_runs?"],
|
||||||
|
["record_id", "fk:source_records?"],
|
||||||
|
["type", "t"],
|
||||||
|
["target_entity", "t"],
|
||||||
|
["target_id", "t?"],
|
||||||
|
["payload", "t"],
|
||||||
|
["status", "t"],
|
||||||
|
["created_at", "t"],
|
||||||
|
["resolved_at", "t?"],
|
||||||
|
],
|
||||||
|
{ indexes: [["user_id", "status"]] },
|
||||||
|
);
|
||||||
|
await create(
|
||||||
|
db,
|
||||||
|
"jobs",
|
||||||
|
[
|
||||||
|
["type", "t"],
|
||||||
|
["payload", "t"],
|
||||||
|
["run_at", "t"],
|
||||||
|
["status", "t"],
|
||||||
|
["attempts", "i"],
|
||||||
|
["last_error", "t?"],
|
||||||
|
["locked_at", "t?"],
|
||||||
|
["lock_key", "t?"],
|
||||||
|
["created_at", "t"],
|
||||||
|
],
|
||||||
|
{ indexes: [["status", "run_at"], ["lock_key"]] },
|
||||||
|
);
|
||||||
|
},
|
||||||
|
|
||||||
|
async down(db: Kysely<any>) {
|
||||||
|
for (const table of [
|
||||||
|
"jobs",
|
||||||
|
"change_events",
|
||||||
|
"source_record_versions",
|
||||||
|
"source_records",
|
||||||
|
"source_runs",
|
||||||
|
"match_suggestions",
|
||||||
|
"user_history",
|
||||||
|
"attachment_links",
|
||||||
|
"attachments",
|
||||||
|
"copies",
|
||||||
|
"custom_field_definitions",
|
||||||
|
"list_items",
|
||||||
|
"lists",
|
||||||
|
"listens",
|
||||||
|
"engagement_sources",
|
||||||
|
"source_connections",
|
||||||
|
"engagements",
|
||||||
|
"entries",
|
||||||
|
"work_history",
|
||||||
|
"work_aliases",
|
||||||
|
"external_ids",
|
||||||
|
"works",
|
||||||
|
"app_tokens",
|
||||||
|
"invites",
|
||||||
|
"sessions",
|
||||||
|
"users",
|
||||||
|
]) {
|
||||||
|
await db.schema.dropTable(table).execute();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
};
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
import type { Kysely } from "kysely";
|
||||||
|
import type { Migration } from "kysely/migration";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* One child per (parent, position), so concurrent matches cannot duplicate seasons or episodes.
|
||||||
|
* NULLs are distinct on both dialects: top-level works and children without a position are unaffected.
|
||||||
|
*/
|
||||||
|
export const worksPositionUnique: Migration = {
|
||||||
|
async up(db: Kysely<any>) {
|
||||||
|
await db.schema.createIndex("works_parent_id_position_uq").unique().on("works").columns(["parent_id", "position"]).execute();
|
||||||
|
},
|
||||||
|
|
||||||
|
async down(db: Kysely<any>) {
|
||||||
|
await db.schema.dropIndex("works_parent_id_position_uq").execute();
|
||||||
|
},
|
||||||
|
};
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
import type { Migration } from "kysely/migration";
|
||||||
|
import { initial } from "./0001_initial.ts";
|
||||||
|
import { worksPositionUnique } from "./0002_works_position_unique.ts";
|
||||||
|
|
||||||
|
/** All migrations, keyed by sortable name. Imported statically so bundles need no filesystem. */
|
||||||
|
export const migrations: Record<string, Migration> = {
|
||||||
|
"0001_initial": initial,
|
||||||
|
"0002_works_position_unique": worksPositionUnique,
|
||||||
|
};
|
||||||
@@ -0,0 +1,364 @@
|
|||||||
|
/**
|
||||||
|
* Kysely table types. snake_case columns; timestamps are ISO-8601 text; JSON columns are text
|
||||||
|
* (use `toJson`/`fromJson`); booleans are integer 0/1. All IDs come from app code (`newId()`),
|
||||||
|
* so nothing here is `Generated<>`.
|
||||||
|
*/
|
||||||
|
|
||||||
|
export type Role = "admin" | "member";
|
||||||
|
export type EntryStatus = "planned" | "in_progress" | "paused" | "completed" | "dropped" | "none";
|
||||||
|
export type Visibility = "private" | "friends" | "public";
|
||||||
|
export type TokenScope = "read" | "write" | "ingest";
|
||||||
|
export type CustomFieldLevel = "entry" | "engagement" | "copy";
|
||||||
|
export type ListKind = "list" | "grouping" | "thread";
|
||||||
|
export type AttachmentEntity = "entry" | "engagement" | "copy" | "list" | "work";
|
||||||
|
export type SuggestionStatus = "pending" | "accepted" | "rejected";
|
||||||
|
export type ConnectionStatus = "active" | "paused" | "error";
|
||||||
|
export type RunMode = "full" | "changes" | "file";
|
||||||
|
export type RunStatus = "running" | "ok" | "failed";
|
||||||
|
export type ChangeType = "added" | "changed" | "removed" | "merged" | "conflict";
|
||||||
|
export type ChangeStatus = "pending" | "auto_applied" | "accepted" | "ignored" | "kept_mine";
|
||||||
|
export type JobStatus = "queued" | "running" | "done" | "failed";
|
||||||
|
|
||||||
|
export interface UsersTable {
|
||||||
|
id: string;
|
||||||
|
username: string;
|
||||||
|
display_name: string;
|
||||||
|
password_hash: string;
|
||||||
|
role: Role;
|
||||||
|
preferences: string;
|
||||||
|
created_at: string;
|
||||||
|
updated_at: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SessionsTable {
|
||||||
|
id: string;
|
||||||
|
user_id: string;
|
||||||
|
token_hash: string;
|
||||||
|
expires_at: string;
|
||||||
|
created_at: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface InvitesTable {
|
||||||
|
id: string;
|
||||||
|
code_hash: string;
|
||||||
|
created_by: string;
|
||||||
|
used_by: string | null;
|
||||||
|
expires_at: string;
|
||||||
|
created_at: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AppTokensTable {
|
||||||
|
id: string;
|
||||||
|
user_id: string;
|
||||||
|
name: string;
|
||||||
|
token_hash: string;
|
||||||
|
/** JSON `TokenScope[]`. */
|
||||||
|
scopes: string;
|
||||||
|
last_used_at: string | null;
|
||||||
|
revoked_at: string | null;
|
||||||
|
created_at: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface WorksTable {
|
||||||
|
id: string;
|
||||||
|
media_type: string;
|
||||||
|
title: string;
|
||||||
|
original_title: string;
|
||||||
|
year: number | null;
|
||||||
|
release_date: string | null;
|
||||||
|
creators: string;
|
||||||
|
description: string;
|
||||||
|
cover_url: string;
|
||||||
|
cover_attachment_id: string | null;
|
||||||
|
metadata: string;
|
||||||
|
parent_id: string | null;
|
||||||
|
position: string | null;
|
||||||
|
owner_user_id: string | null;
|
||||||
|
merged_into_id: string | null;
|
||||||
|
created_at: string;
|
||||||
|
updated_at: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ExternalIdsTable {
|
||||||
|
id: string;
|
||||||
|
work_id: string;
|
||||||
|
namespace: string;
|
||||||
|
value: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface WorkAliasesTable {
|
||||||
|
id: string;
|
||||||
|
user_id: string;
|
||||||
|
from_work_id: string;
|
||||||
|
to_work_id: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface WorkHistoryTable {
|
||||||
|
id: string;
|
||||||
|
work_id: string;
|
||||||
|
before: string;
|
||||||
|
after: string;
|
||||||
|
actor: string;
|
||||||
|
created_at: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface EntriesTable {
|
||||||
|
id: string;
|
||||||
|
user_id: string;
|
||||||
|
work_id: string;
|
||||||
|
status: EntryStatus;
|
||||||
|
rating: number | null;
|
||||||
|
review: string | null;
|
||||||
|
private_notes: string | null;
|
||||||
|
/** 0 or 1. */
|
||||||
|
favorite: number;
|
||||||
|
tags: string;
|
||||||
|
custom_fields: string;
|
||||||
|
visibility: Visibility;
|
||||||
|
overrides: string;
|
||||||
|
media_type_override: string | null;
|
||||||
|
field_sources: string;
|
||||||
|
created_at: string;
|
||||||
|
updated_at: string;
|
||||||
|
deleted_at: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface EngagementsTable {
|
||||||
|
id: string;
|
||||||
|
entry_id: string;
|
||||||
|
started_at: string | null;
|
||||||
|
started_precision: string;
|
||||||
|
finished_at: string | null;
|
||||||
|
finished_precision: string;
|
||||||
|
progress: string;
|
||||||
|
platform: string | null;
|
||||||
|
rating: number | null;
|
||||||
|
notes: string | null;
|
||||||
|
custom_fields: string;
|
||||||
|
field_sources: string;
|
||||||
|
created_at: string;
|
||||||
|
updated_at: string;
|
||||||
|
deleted_at: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface EngagementSourcesTable {
|
||||||
|
id: string;
|
||||||
|
engagement_id: string;
|
||||||
|
connection_id: string;
|
||||||
|
record_key: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ListensTable {
|
||||||
|
id: string;
|
||||||
|
user_id: string;
|
||||||
|
listened_at: string;
|
||||||
|
artist: string;
|
||||||
|
track: string;
|
||||||
|
album: string | null;
|
||||||
|
duration_ms: number | null;
|
||||||
|
mbids: string;
|
||||||
|
work_id: string | null;
|
||||||
|
source: string;
|
||||||
|
source_key: string;
|
||||||
|
created_at: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ListsTable {
|
||||||
|
id: string;
|
||||||
|
user_id: string;
|
||||||
|
name: string;
|
||||||
|
description: string;
|
||||||
|
kind: ListKind;
|
||||||
|
visibility: Visibility;
|
||||||
|
rating: number | null;
|
||||||
|
review: string | null;
|
||||||
|
created_at: string;
|
||||||
|
updated_at: string;
|
||||||
|
deleted_at: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ListItemsTable {
|
||||||
|
id: string;
|
||||||
|
list_id: string;
|
||||||
|
work_id: string;
|
||||||
|
position: number;
|
||||||
|
note: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CustomFieldDefinitionsTable {
|
||||||
|
id: string;
|
||||||
|
user_id: string;
|
||||||
|
name: string;
|
||||||
|
type: string;
|
||||||
|
options: string;
|
||||||
|
/** JSON `MediaType[] | "all"`. */
|
||||||
|
applies_to: string;
|
||||||
|
level: CustomFieldLevel;
|
||||||
|
created_at: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CopiesTable {
|
||||||
|
id: string;
|
||||||
|
user_id: string;
|
||||||
|
work_id: string | null;
|
||||||
|
title_hint: string | null;
|
||||||
|
format: string | null;
|
||||||
|
acquired_from: string | null;
|
||||||
|
acquired_at: string | null;
|
||||||
|
acquired_precision: string;
|
||||||
|
price_cents: number | null;
|
||||||
|
currency: string | null;
|
||||||
|
notes: string | null;
|
||||||
|
custom_fields: string;
|
||||||
|
source_ref: string | null;
|
||||||
|
created_at: string;
|
||||||
|
updated_at: string;
|
||||||
|
deleted_at: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AttachmentsTable {
|
||||||
|
id: string;
|
||||||
|
user_id: string;
|
||||||
|
sha256: string;
|
||||||
|
mime: string;
|
||||||
|
size: number;
|
||||||
|
filename: string;
|
||||||
|
caption: string | null;
|
||||||
|
taken_at: string | null;
|
||||||
|
visibility: Visibility | null;
|
||||||
|
created_at: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AttachmentLinksTable {
|
||||||
|
id: string;
|
||||||
|
attachment_id: string;
|
||||||
|
entity: AttachmentEntity;
|
||||||
|
entity_id: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UserHistoryTable {
|
||||||
|
id: string;
|
||||||
|
user_id: string;
|
||||||
|
entity: string;
|
||||||
|
entity_id: string;
|
||||||
|
before: string;
|
||||||
|
after: string;
|
||||||
|
actor: string;
|
||||||
|
created_at: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface MatchSuggestionsTable {
|
||||||
|
id: string;
|
||||||
|
user_id: string;
|
||||||
|
work_id: string;
|
||||||
|
candidate_work_id: string;
|
||||||
|
score: number;
|
||||||
|
status: SuggestionStatus;
|
||||||
|
created_at: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SourceConnectionsTable {
|
||||||
|
id: string;
|
||||||
|
user_id: string;
|
||||||
|
plugin_id: string;
|
||||||
|
name: string;
|
||||||
|
config: string;
|
||||||
|
credentials_enc: string | null;
|
||||||
|
schedule_minutes: number | null;
|
||||||
|
cursor: string | null;
|
||||||
|
status: ConnectionStatus;
|
||||||
|
last_run_at: string | null;
|
||||||
|
created_at: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SourceRunsTable {
|
||||||
|
id: string;
|
||||||
|
connection_id: string;
|
||||||
|
mode: RunMode;
|
||||||
|
started_at: string;
|
||||||
|
finished_at: string | null;
|
||||||
|
status: RunStatus;
|
||||||
|
counts: string;
|
||||||
|
log: string;
|
||||||
|
error: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SourceRecordsTable {
|
||||||
|
id: string;
|
||||||
|
connection_id: string;
|
||||||
|
key: string;
|
||||||
|
kind: string;
|
||||||
|
current_hash: string;
|
||||||
|
first_seen: string;
|
||||||
|
last_seen: string;
|
||||||
|
deleted_at: string | null;
|
||||||
|
merged_into_key: string | null;
|
||||||
|
target_entity: string | null;
|
||||||
|
target_id: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SourceRecordVersionsTable {
|
||||||
|
id: string;
|
||||||
|
record_id: string;
|
||||||
|
hash: string;
|
||||||
|
/** Base64 of gzipped raw record. */
|
||||||
|
raw_gz: string;
|
||||||
|
seen_at: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ChangeEventsTable {
|
||||||
|
id: string;
|
||||||
|
user_id: string;
|
||||||
|
connection_id: string | null;
|
||||||
|
run_id: string | null;
|
||||||
|
record_id: string | null;
|
||||||
|
type: ChangeType;
|
||||||
|
target_entity: string;
|
||||||
|
target_id: string | null;
|
||||||
|
payload: string;
|
||||||
|
status: ChangeStatus;
|
||||||
|
created_at: string;
|
||||||
|
resolved_at: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface JobsTable {
|
||||||
|
id: string;
|
||||||
|
type: string;
|
||||||
|
payload: string;
|
||||||
|
run_at: string;
|
||||||
|
status: JobStatus;
|
||||||
|
attempts: number;
|
||||||
|
last_error: string | null;
|
||||||
|
locked_at: string | null;
|
||||||
|
lock_key: string | null;
|
||||||
|
created_at: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Database {
|
||||||
|
users: UsersTable;
|
||||||
|
sessions: SessionsTable;
|
||||||
|
invites: InvitesTable;
|
||||||
|
app_tokens: AppTokensTable;
|
||||||
|
works: WorksTable;
|
||||||
|
external_ids: ExternalIdsTable;
|
||||||
|
work_aliases: WorkAliasesTable;
|
||||||
|
work_history: WorkHistoryTable;
|
||||||
|
entries: EntriesTable;
|
||||||
|
engagements: EngagementsTable;
|
||||||
|
engagement_sources: EngagementSourcesTable;
|
||||||
|
listens: ListensTable;
|
||||||
|
lists: ListsTable;
|
||||||
|
list_items: ListItemsTable;
|
||||||
|
custom_field_definitions: CustomFieldDefinitionsTable;
|
||||||
|
copies: CopiesTable;
|
||||||
|
attachments: AttachmentsTable;
|
||||||
|
attachment_links: AttachmentLinksTable;
|
||||||
|
user_history: UserHistoryTable;
|
||||||
|
match_suggestions: MatchSuggestionsTable;
|
||||||
|
source_connections: SourceConnectionsTable;
|
||||||
|
source_runs: SourceRunsTable;
|
||||||
|
source_records: SourceRecordsTable;
|
||||||
|
source_record_versions: SourceRecordVersionsTable;
|
||||||
|
change_events: ChangeEventsTable;
|
||||||
|
jobs: JobsTable;
|
||||||
|
}
|
||||||
@@ -0,0 +1,88 @@
|
|||||||
|
import { SQL } from "bun";
|
||||||
|
import { afterAll, afterEach, beforeAll, beforeEach, describe } from "bun:test";
|
||||||
|
import { createDb, type Db } from "./db.ts";
|
||||||
|
import type { Dialect } from "./dialect.ts";
|
||||||
|
import { migrateToLatest } from "./migrate.ts";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Register `describe("<name> [<dialect>]")` for SQLite, and for Postgres when
|
||||||
|
* `TEST_POSTGRES_URL` is set. Every test gets a fresh, migrated database:
|
||||||
|
* SQLite `:memory:`, or a new `st_<random>` database on the Postgres server.
|
||||||
|
*/
|
||||||
|
export function forEachDialect(name: string, fn: (getDb: () => Db) => void): void {
|
||||||
|
const dialects: Dialect[] = ["sqlite"];
|
||||||
|
const pgUrl = process.env.TEST_POSTGRES_URL;
|
||||||
|
if (pgUrl) dialects.push("postgres");
|
||||||
|
|
||||||
|
for (const dialect of dialects) {
|
||||||
|
describe(`${name} [${dialect}]`, () => {
|
||||||
|
let current: Db | undefined;
|
||||||
|
let currentName: string | undefined;
|
||||||
|
const template = `st_tpl_${crypto.randomUUID().replaceAll("-", "")}`;
|
||||||
|
|
||||||
|
if (dialect === "postgres") {
|
||||||
|
// Migrate one template per describe block, then clone it per test (much faster).
|
||||||
|
beforeAll(async () => {
|
||||||
|
await withAdmin(pgUrl!, (admin) => admin.unsafe(`create database "${template}"`));
|
||||||
|
const tpl = await createDb(urlFor(pgUrl!, template));
|
||||||
|
try {
|
||||||
|
await migrateToLatest(tpl);
|
||||||
|
} finally {
|
||||||
|
await tpl.close();
|
||||||
|
}
|
||||||
|
}, HOOK_TIMEOUT_MS);
|
||||||
|
}
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
if (dialect === "postgres") {
|
||||||
|
currentName = `st_${crypto.randomUUID().replaceAll("-", "")}`;
|
||||||
|
await withAdmin(pgUrl!, (admin) =>
|
||||||
|
admin.unsafe(`create database "${currentName}" template "${template}"`),
|
||||||
|
);
|
||||||
|
current = await createDb(urlFor(pgUrl!, currentName));
|
||||||
|
} else {
|
||||||
|
current = await createDb("sqlite://:memory:");
|
||||||
|
await migrateToLatest(current);
|
||||||
|
}
|
||||||
|
}, HOOK_TIMEOUT_MS);
|
||||||
|
|
||||||
|
afterEach(async () => {
|
||||||
|
await current?.close();
|
||||||
|
current = undefined;
|
||||||
|
if (currentName) {
|
||||||
|
const dbName = currentName;
|
||||||
|
currentName = undefined;
|
||||||
|
await withAdmin(pgUrl!, (admin) => admin.unsafe(`drop database if exists "${dbName}" with (force)`));
|
||||||
|
}
|
||||||
|
}, HOOK_TIMEOUT_MS);
|
||||||
|
|
||||||
|
if (dialect === "postgres") {
|
||||||
|
afterAll(async () => {
|
||||||
|
await withAdmin(pgUrl!, (admin) => admin.unsafe(`drop database if exists "${template}" with (force)`));
|
||||||
|
}, HOOK_TIMEOUT_MS);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn(() => {
|
||||||
|
if (!current) throw new Error("getDb() called outside a test");
|
||||||
|
return current;
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const HOOK_TIMEOUT_MS = 30_000;
|
||||||
|
|
||||||
|
function urlFor(base: string, dbName: string): string {
|
||||||
|
const u = new URL(base);
|
||||||
|
u.pathname = `/${dbName}`;
|
||||||
|
return u.toString();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function withAdmin<T>(url: string, f: (admin: SQL) => Promise<T>): Promise<T> {
|
||||||
|
const admin = new SQL(url);
|
||||||
|
try {
|
||||||
|
return await f(admin);
|
||||||
|
} finally {
|
||||||
|
await admin.close();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,63 @@
|
|||||||
|
import { expect, test } from "bun:test";
|
||||||
|
import { sql } from "kysely";
|
||||||
|
import { newId } from "../src/ids.ts";
|
||||||
|
import { fromJson, toJson } from "../src/json.ts";
|
||||||
|
import { forEachDialect } from "../src/testing.ts";
|
||||||
|
|
||||||
|
forEachDialect("db basics", (getDb) => {
|
||||||
|
async function createTempTable() {
|
||||||
|
await sql`create table t (id text primary key, j text not null)`.execute(getDb().k);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function countRows(): Promise<number> {
|
||||||
|
const { rows } = await sql<{ n: number | string }>`select count(*) as n from t`.execute(getDb().k);
|
||||||
|
return Number(rows[0]?.n);
|
||||||
|
}
|
||||||
|
|
||||||
|
test("round-trips a row", async () => {
|
||||||
|
await createTempTable();
|
||||||
|
const id = newId();
|
||||||
|
await sql`insert into t (id, j) values (${id}, ${toJson({ a: 1 })})`.execute(getDb().k);
|
||||||
|
const { rows } = await sql<{ id: string; j: string }>`select id, j from t`.execute(getDb().k);
|
||||||
|
expect(rows).toHaveLength(1);
|
||||||
|
const row = rows[0]!;
|
||||||
|
expect(row.id).toBe(id);
|
||||||
|
expect(fromJson(row.j, {})).toEqual({ a: 1 });
|
||||||
|
});
|
||||||
|
|
||||||
|
test("transactions roll back", async () => {
|
||||||
|
await createTempTable();
|
||||||
|
await expect(
|
||||||
|
getDb()
|
||||||
|
.k.transaction()
|
||||||
|
.execute(async (tx) => {
|
||||||
|
await sql`insert into t (id, j) values (${newId()}, ${toJson(null)})`.execute(tx);
|
||||||
|
throw new Error("x");
|
||||||
|
}),
|
||||||
|
).rejects.toThrow("x");
|
||||||
|
expect(await countRows()).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("transactions commit", async () => {
|
||||||
|
await createTempTable();
|
||||||
|
await getDb()
|
||||||
|
.k.transaction()
|
||||||
|
.execute(async (tx) => {
|
||||||
|
await sql`insert into t (id, j) values (${newId()}, ${toJson([1])})`.execute(tx);
|
||||||
|
});
|
||||||
|
expect(await countRows()).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("newId is time-sortable", () => {
|
||||||
|
const ids = Array.from({ length: 1000 }, () => newId());
|
||||||
|
expect([...ids].sort()).toEqual(ids);
|
||||||
|
expect(new Set(ids).size).toBe(1000);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test("fromJson falls back on null, undefined and invalid input", () => {
|
||||||
|
expect(fromJson(null, 5)).toBe(5);
|
||||||
|
expect(fromJson(undefined, "d")).toBe("d");
|
||||||
|
expect(fromJson("{not json", [])).toEqual([]);
|
||||||
|
expect(fromJson(toJson({ b: [1, "x"] }), {})).toEqual({ b: [1, "x"] });
|
||||||
|
});
|
||||||
@@ -0,0 +1,109 @@
|
|||||||
|
import { expect, test } from "bun:test";
|
||||||
|
import { sql, type Kysely } from "kysely";
|
||||||
|
import { Migrator, NO_MIGRATIONS } from "kysely/migration";
|
||||||
|
import { newId, nowIso } from "../src/ids.ts";
|
||||||
|
import { toJson } from "../src/json.ts";
|
||||||
|
import { migrations } from "../src/migrations/index.ts";
|
||||||
|
import { forEachDialect } from "../src/testing.ts";
|
||||||
|
|
||||||
|
forEachDialect("schema", (getDb) => {
|
||||||
|
test("all tables exist and unique constraints hold", async () => {
|
||||||
|
const k = getDb().k;
|
||||||
|
const now = nowIso();
|
||||||
|
const userId = newId();
|
||||||
|
await k
|
||||||
|
.insertInto("users")
|
||||||
|
.values({
|
||||||
|
id: userId,
|
||||||
|
username: "ada",
|
||||||
|
display_name: "Ada",
|
||||||
|
password_hash: "x",
|
||||||
|
role: "admin",
|
||||||
|
preferences: toJson({}),
|
||||||
|
created_at: now,
|
||||||
|
updated_at: now,
|
||||||
|
})
|
||||||
|
.execute();
|
||||||
|
|
||||||
|
const workId = newId();
|
||||||
|
await k
|
||||||
|
.insertInto("works")
|
||||||
|
.values({
|
||||||
|
id: workId,
|
||||||
|
media_type: "book",
|
||||||
|
title: "Dune",
|
||||||
|
original_title: "Dune",
|
||||||
|
year: 1965,
|
||||||
|
release_date: null,
|
||||||
|
creators: toJson([]),
|
||||||
|
description: "",
|
||||||
|
cover_url: "",
|
||||||
|
cover_attachment_id: null,
|
||||||
|
metadata: toJson({}),
|
||||||
|
parent_id: null,
|
||||||
|
position: null,
|
||||||
|
owner_user_id: null,
|
||||||
|
merged_into_id: null,
|
||||||
|
created_at: now,
|
||||||
|
updated_at: now,
|
||||||
|
})
|
||||||
|
.execute();
|
||||||
|
|
||||||
|
await k
|
||||||
|
.insertInto("external_ids")
|
||||||
|
.values({ id: newId(), work_id: workId, namespace: "isbn", value: "123" })
|
||||||
|
.execute();
|
||||||
|
await expect(
|
||||||
|
k.insertInto("external_ids").values({ id: newId(), work_id: workId, namespace: "isbn", value: "123" }).execute(),
|
||||||
|
).rejects.toThrow();
|
||||||
|
|
||||||
|
const entry = {
|
||||||
|
user_id: userId,
|
||||||
|
work_id: workId,
|
||||||
|
status: "planned" as const,
|
||||||
|
rating: null,
|
||||||
|
review: null,
|
||||||
|
private_notes: null,
|
||||||
|
favorite: 0,
|
||||||
|
tags: toJson([]),
|
||||||
|
custom_fields: toJson({}),
|
||||||
|
visibility: "private" as const,
|
||||||
|
overrides: toJson({}),
|
||||||
|
media_type_override: null,
|
||||||
|
field_sources: toJson({}),
|
||||||
|
created_at: now,
|
||||||
|
updated_at: now,
|
||||||
|
deleted_at: null,
|
||||||
|
};
|
||||||
|
await k.insertInto("entries").values({ id: newId(), ...entry }).execute();
|
||||||
|
await expect(k.insertInto("entries").values({ id: newId(), ...entry }).execute()).rejects.toThrow();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("works (parent_id, position) is unique; null positions and null parents are not", async () => {
|
||||||
|
const k = getDb().k;
|
||||||
|
const now = nowIso();
|
||||||
|
const work = (id: string, parent: string | null, position: string | null) => ({
|
||||||
|
id, media_type: "show", title: "T", original_title: "", year: null, release_date: null, creators: "[]",
|
||||||
|
description: "", cover_url: "", cover_attachment_id: null, metadata: "{}", parent_id: parent, position,
|
||||||
|
owner_user_id: null, merged_into_id: null, created_at: now, updated_at: now,
|
||||||
|
});
|
||||||
|
const show = newId();
|
||||||
|
await k.insertInto("works").values(work(show, null, null)).execute();
|
||||||
|
await k.insertInto("works").values(work(newId(), null, null)).execute();
|
||||||
|
await k.insertInto("works").values(work(newId(), null, '{"season":1}')).execute();
|
||||||
|
await k.insertInto("works").values(work(newId(), null, '{"season":1}')).execute();
|
||||||
|
await k.insertInto("works").values(work(newId(), show, null)).execute();
|
||||||
|
await k.insertInto("works").values(work(newId(), show, null)).execute();
|
||||||
|
await k.insertInto("works").values(work(newId(), show, '{"season":1}')).execute();
|
||||||
|
await expect(k.insertInto("works").values(work(newId(), show, '{"season":1}')).execute()).rejects.toThrow();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("migrating down removes the tables", async () => {
|
||||||
|
const k = getDb().k as Kysely<any>;
|
||||||
|
const migrator = new Migrator({ db: k, provider: { getMigrations: async () => migrations } });
|
||||||
|
const { error } = await migrator.migrateTo(NO_MIGRATIONS);
|
||||||
|
expect(error).toBeUndefined();
|
||||||
|
const probe = sql`select 1 from users`.execute(k);
|
||||||
|
await expect(probe).rejects.toThrow();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
{
|
||||||
|
"name": "@sttracker/plugin-sdk",
|
||||||
|
"private": true,
|
||||||
|
"type": "module",
|
||||||
|
"exports": {
|
||||||
|
"./*": "./src/*.ts"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"fflate": "^0.8.3"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,82 @@
|
|||||||
|
export type CsvDelimiter = "," | ";" | "\t";
|
||||||
|
|
||||||
|
function detectDelimiter(text: string): CsvDelimiter {
|
||||||
|
const counts: Record<CsvDelimiter, number> = { ",": 0, ";": 0, "\t": 0 };
|
||||||
|
let inQuotes = false;
|
||||||
|
for (const ch of text) {
|
||||||
|
if (ch === '"') inQuotes = !inQuotes;
|
||||||
|
else if (!inQuotes) {
|
||||||
|
if (ch === "\n" || ch === "\r") break;
|
||||||
|
if (ch === "," || ch === ";" || ch === "\t") counts[ch]++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let best: CsvDelimiter = ",";
|
||||||
|
for (const d of [";", "\t"] as const) if (counts[d] > counts[best]) best = d;
|
||||||
|
return best;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** RFC 4180 parser. Strips a BOM, auto-detects the delimiter from the header line, skips blank lines. */
|
||||||
|
export function parseCsv(text: string, opts: { delimiter?: CsvDelimiter } = {}): string[][] {
|
||||||
|
if (text.charCodeAt(0) === 0xfeff) text = text.slice(1);
|
||||||
|
const delim = opts.delimiter ?? detectDelimiter(text);
|
||||||
|
const rows: string[][] = [];
|
||||||
|
let row: string[] = [];
|
||||||
|
let field = "";
|
||||||
|
let quoted = false; // current field was quoted
|
||||||
|
let inQuotes = false;
|
||||||
|
let pending = false; // something has been read for the current row
|
||||||
|
|
||||||
|
const endField = () => {
|
||||||
|
row.push(field);
|
||||||
|
field = "";
|
||||||
|
};
|
||||||
|
const endRow = () => {
|
||||||
|
const blank = row.length === 0 && field === "" && !quoted;
|
||||||
|
if (!blank) {
|
||||||
|
endField();
|
||||||
|
rows.push(row);
|
||||||
|
}
|
||||||
|
row = [];
|
||||||
|
field = "";
|
||||||
|
quoted = false;
|
||||||
|
pending = false;
|
||||||
|
};
|
||||||
|
|
||||||
|
for (let i = 0; i < text.length; i++) {
|
||||||
|
const ch = text[i]!;
|
||||||
|
if (inQuotes) {
|
||||||
|
if (ch === '"') {
|
||||||
|
if (text[i + 1] === '"') {
|
||||||
|
field += '"';
|
||||||
|
i++;
|
||||||
|
} else inQuotes = false;
|
||||||
|
} else field += ch;
|
||||||
|
} else if (ch === '"' && field === "" && !quoted) {
|
||||||
|
inQuotes = true;
|
||||||
|
quoted = true;
|
||||||
|
pending = true;
|
||||||
|
} else if (ch === delim) {
|
||||||
|
endField();
|
||||||
|
quoted = false;
|
||||||
|
pending = true;
|
||||||
|
} else if (ch === "\r" || ch === "\n") {
|
||||||
|
if (ch === "\r" && text[i + 1] === "\n") i++;
|
||||||
|
endRow();
|
||||||
|
} else {
|
||||||
|
field += ch;
|
||||||
|
pending = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (pending || field !== "" || row.length > 0) endRow();
|
||||||
|
return rows;
|
||||||
|
}
|
||||||
|
|
||||||
|
function quoteCell(s: string, single: boolean): string {
|
||||||
|
if (s === "" ? single : /[",;\t\r\n]/.test(s) || s !== s.trim()) return `"${s.replaceAll('"', '""')}"`;
|
||||||
|
return s;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Serialises with `,` and `\r\n`, quoting only where needed. */
|
||||||
|
export function toCsv(rows: string[][]): string {
|
||||||
|
return rows.map((r) => r.map((c) => quoteCell(c, r.length === 1)).join(",") + "\r\n").join("");
|
||||||
|
}
|
||||||
@@ -0,0 +1,111 @@
|
|||||||
|
import type { HttpClient } from "./types.ts";
|
||||||
|
|
||||||
|
/** Origin + pathname only: query strings may carry API keys or tokens. */
|
||||||
|
function safeUrl(url: string): string {
|
||||||
|
try {
|
||||||
|
const u = new URL(url);
|
||||||
|
return u.origin + u.pathname;
|
||||||
|
} catch {
|
||||||
|
return url.split(/[?#]/)[0] ?? "";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export class HttpError extends Error {
|
||||||
|
readonly status: number;
|
||||||
|
readonly body: string;
|
||||||
|
constructor(status: number, body: string, url = "") {
|
||||||
|
super(`HTTP ${status}${url ? ` for ${safeUrl(url)}` : ""}`);
|
||||||
|
this.name = "HttpError";
|
||||||
|
this.status = status;
|
||||||
|
this.body = body;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface HttpClientOptions {
|
||||||
|
ratePerMinute?: number;
|
||||||
|
burst?: number;
|
||||||
|
maxRetries?: number;
|
||||||
|
userAgent: string;
|
||||||
|
fetchImpl?: typeof fetch;
|
||||||
|
sleep?: (ms: number) => Promise<void>;
|
||||||
|
now?: () => number;
|
||||||
|
}
|
||||||
|
|
||||||
|
const retryable = (status: number) => status === 429 || status >= 500;
|
||||||
|
|
||||||
|
function retryDelayMs(res: Response, attempt: number, now: number): number {
|
||||||
|
const ra = res.headers.get("retry-after");
|
||||||
|
if (ra !== null && ra.trim() !== "") {
|
||||||
|
const secs = Number(ra);
|
||||||
|
if (Number.isFinite(secs)) return Math.max(0, secs * 1000);
|
||||||
|
const at = Date.parse(ra);
|
||||||
|
if (!Number.isNaN(at)) return Math.max(0, at - now);
|
||||||
|
}
|
||||||
|
const reset = res.headers.get("x-ratelimit-reset-in");
|
||||||
|
if (reset !== null) {
|
||||||
|
const secs = Number(reset);
|
||||||
|
if (Number.isFinite(secs)) return Math.max(0, secs * 1000);
|
||||||
|
}
|
||||||
|
return 500 * 2 ** attempt;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createHttpClient(opts: HttpClientOptions): HttpClient {
|
||||||
|
const doFetch = opts.fetchImpl ?? fetch;
|
||||||
|
const sleep = opts.sleep ?? ((ms: number) => new Promise<void>((r) => setTimeout(r, ms)));
|
||||||
|
const now = opts.now ?? Date.now;
|
||||||
|
const maxRetries = opts.maxRetries ?? 3;
|
||||||
|
|
||||||
|
// Token bucket: capacity `burst`, refilling at ratePerMinute / 60000 tokens per ms.
|
||||||
|
const perMs = opts.ratePerMinute ? opts.ratePerMinute / 60_000 : 0;
|
||||||
|
const capacity = Math.max(1, opts.burst ?? 1);
|
||||||
|
let tokens = capacity;
|
||||||
|
let last = now();
|
||||||
|
let queue: Promise<void> = Promise.resolve();
|
||||||
|
|
||||||
|
async function take(): Promise<void> {
|
||||||
|
for (;;) {
|
||||||
|
const t = now();
|
||||||
|
tokens = Math.min(capacity, tokens + (t - last) * perMs);
|
||||||
|
last = t;
|
||||||
|
if (tokens >= 1 - 1e-9) {
|
||||||
|
tokens -= 1;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await sleep(Math.ceil((1 - tokens) / perMs));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
function acquire(): Promise<void> {
|
||||||
|
if (!perMs) return Promise.resolve();
|
||||||
|
const p = queue.then(take);
|
||||||
|
queue = p.catch(() => {});
|
||||||
|
return p;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function send(url: string, init: RequestInit | undefined): Promise<Response> {
|
||||||
|
const headers = new Headers(init?.headers);
|
||||||
|
if (!headers.has("user-agent")) headers.set("user-agent", opts.userAgent);
|
||||||
|
for (let attempt = 0; ; attempt++) {
|
||||||
|
await acquire();
|
||||||
|
const res = await doFetch(url, { ...init, headers });
|
||||||
|
if (!retryable(res.status)) return res;
|
||||||
|
if (attempt >= maxRetries) throw new HttpError(res.status, await res.text(), url);
|
||||||
|
const delay = retryDelayMs(res, attempt, now());
|
||||||
|
await res.body?.cancel();
|
||||||
|
await sleep(delay);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
raw: (url, init) => send(url, init),
|
||||||
|
async json<T>(url: string, init?: RequestInit & { query?: Record<string, string | number | undefined> }): Promise<T> {
|
||||||
|
const { query, ...rest } = init ?? {};
|
||||||
|
const u = new URL(url);
|
||||||
|
for (const [k, v] of Object.entries(query ?? {})) if (v !== undefined) u.searchParams.set(k, String(v));
|
||||||
|
const headers = new Headers(rest.headers);
|
||||||
|
if (!headers.has("accept")) headers.set("accept", "application/json");
|
||||||
|
const res = await send(u.toString(), { ...rest, headers });
|
||||||
|
if (!res.ok) throw new HttpError(res.status, await res.text(), u.toString());
|
||||||
|
return (await res.json()) as T;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
import type { PartialDate } from "./types.ts";
|
||||||
|
|
||||||
|
const RE = /^(\d{4})(?:-(\d{2})(?:-(\d{2})(T\d{2}:\d{2}(?::\d{2}(?:\.\d+)?)?(?:Z|[+-]\d{2}:?\d{2})?)?)?)?$/;
|
||||||
|
|
||||||
|
/** Parses by string only; never goes through Date, so no timezone shift. */
|
||||||
|
export function parsePartialDate(input: string | null | undefined): PartialDate | null {
|
||||||
|
if (input == null) return null;
|
||||||
|
const s = input.trim();
|
||||||
|
const m = RE.exec(s);
|
||||||
|
if (!m) return null;
|
||||||
|
const year = Number(m[1]);
|
||||||
|
if (m[2] === undefined) return { value: s, precision: "year" };
|
||||||
|
const month = Number(m[2]);
|
||||||
|
if (month < 1 || month > 12) return null;
|
||||||
|
if (m[3] === undefined) return { value: s, precision: "month" };
|
||||||
|
const day = Number(m[3]);
|
||||||
|
const dim = new Date(Date.UTC(year, month, 0)).getUTCDate();
|
||||||
|
if (day < 1 || day > dim) return null;
|
||||||
|
return { value: s, precision: "day" };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function comparePartialDates(a: PartialDate, b: PartialDate): number {
|
||||||
|
return a.value < b.value ? -1 : a.value > b.value ? 1 : 0;
|
||||||
|
}
|
||||||
@@ -0,0 +1,64 @@
|
|||||||
|
import { createHttpClient } from "./http.ts";
|
||||||
|
import type { PluginContext } from "./types.ts";
|
||||||
|
|
||||||
|
type Route = unknown | ((req: Request) => unknown);
|
||||||
|
|
||||||
|
/** Key is "METHOD full-url" or "METHOD url-without-query". Unknown routes throw. */
|
||||||
|
export function createFixtureHttp(routes: Record<string, Route>): { fetch: typeof fetch; calls: Request[] } {
|
||||||
|
const calls: Request[] = [];
|
||||||
|
const fn = async (input: RequestInfo | URL, init?: RequestInit): Promise<Response> => {
|
||||||
|
const req = new Request(input, init);
|
||||||
|
calls.push(req);
|
||||||
|
const u = new URL(req.url);
|
||||||
|
const full = `${req.method} ${u.toString()}`;
|
||||||
|
const bare = `${req.method} ${u.origin}${u.pathname}`;
|
||||||
|
const key = full in routes ? full : bare in routes ? bare : null;
|
||||||
|
if (key === null) throw new Error(`no fixture route for ${full}`);
|
||||||
|
const r = routes[key];
|
||||||
|
const out = typeof r === "function" ? (r as (req: Request) => unknown)(req) : r;
|
||||||
|
return out instanceof Response ? out : Response.json(out);
|
||||||
|
};
|
||||||
|
return { fetch: fn as typeof fetch, calls };
|
||||||
|
}
|
||||||
|
|
||||||
|
const noFetch = (async () => {
|
||||||
|
throw new Error("createTestContext: no fetch provided");
|
||||||
|
}) as unknown as typeof fetch;
|
||||||
|
|
||||||
|
export function createTestContext(
|
||||||
|
partial: Partial<PluginContext> & { fetch?: typeof fetch } = {},
|
||||||
|
): PluginContext & { saved: { credentials: Record<string, string>[]; cursor: string[] } } {
|
||||||
|
const { fetch: fetchImpl, ...overrides } = partial;
|
||||||
|
const saved = { credentials: [] as Record<string, string>[], cursor: [] as string[] };
|
||||||
|
let creds: Record<string, string> | null = null;
|
||||||
|
let cursor: string | null = null;
|
||||||
|
const ctx: PluginContext = {
|
||||||
|
http: createHttpClient({ userAgent: "sttracker-test/0", fetchImpl: fetchImpl ?? noFetch, sleep: async () => {} }),
|
||||||
|
log: () => {},
|
||||||
|
config: {},
|
||||||
|
credentials: {
|
||||||
|
get: async () => creds,
|
||||||
|
save: async (c) => {
|
||||||
|
creds = c;
|
||||||
|
saved.credentials.push(c);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
cursor: {
|
||||||
|
get: async () => cursor,
|
||||||
|
set: async (c) => {
|
||||||
|
cursor = c;
|
||||||
|
saved.cursor.push(c);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
now: () => new Date(),
|
||||||
|
signal: new AbortController().signal,
|
||||||
|
...overrides,
|
||||||
|
};
|
||||||
|
return Object.assign(ctx, { saved });
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function collect<T>(it: AsyncIterable<T>): Promise<T[]> {
|
||||||
|
const out: T[] = [];
|
||||||
|
for await (const x of it) out.push(x);
|
||||||
|
return out;
|
||||||
|
}
|
||||||
@@ -0,0 +1,51 @@
|
|||||||
|
export type MediaType =
|
||||||
|
| "book" | "comic" | "manga" | "game" | "movie" | "show" | "season" | "episode"
|
||||||
|
| "podcast" | "podcast_episode" | "audio_drama" | "artist" | "album" | "track"
|
||||||
|
| "video" | "other";
|
||||||
|
export type Status = "planned" | "in_progress" | "paused" | "completed" | "dropped" | "none";
|
||||||
|
export type Precision = "day" | "month" | "year" | "unknown";
|
||||||
|
export interface PartialDate { value: string; precision: Precision }
|
||||||
|
export type Capability = "pull" | "pullChanges" | "push" | "ownedLibrary" | "ingest" | "lookup" | "importFile" | "exportFile";
|
||||||
|
export interface ConfigField { key: string; label: string; type: "string" | "secret" | "number" | "boolean" | "select"; required?: boolean; options?: string[]; help?: string }
|
||||||
|
export interface PluginManifest { id: string; name: string; version: string; sdkVersion: 1; mediaTypes: MediaType[]; capabilities: Capability[]; config: ConfigField[]; attribution?: string; rateLimit?: { perMinute: number; burst?: number } }
|
||||||
|
export interface WorkHint { title: string; mediaType: MediaType; year?: number; creators?: { name: string; role?: string }[]; externalIds: Record<string, string>; coverUrl?: string; description?: string; parent?: WorkHint; position?: { season?: number; episode?: number; index?: number } }
|
||||||
|
export interface EntryFields { status?: Status; rating?: number | null; review?: string | null; notes?: string | null; favorite?: boolean; tags?: string[] }
|
||||||
|
export interface EngagementFields { startedAt?: PartialDate | null; finishedAt?: PartialDate | null; progress?: Record<string, number | string>; platform?: string | null; rating?: number | null; notes?: string | null }
|
||||||
|
export interface ListenFields { listenedAt: string; artist: string; track: string; album?: string; durationMs?: number; mbids?: { recording?: string; release?: string; artist?: string } }
|
||||||
|
interface Base { key: string; raw: unknown; deleted?: boolean; mergedIntoKey?: string }
|
||||||
|
export type SourceRecord =
|
||||||
|
| (Base & { kind: "entry"; work: WorkHint; entry: EntryFields })
|
||||||
|
| (Base & { kind: "engagement"; work: WorkHint; engagement: EngagementFields })
|
||||||
|
| (Base & { kind: "listen"; listen: ListenFields })
|
||||||
|
| (Base & { kind: "list"; list: { name: string; description?: string; items: { work: WorkHint; note?: string }[] } });
|
||||||
|
export interface HttpClient {
|
||||||
|
json<T>(url: string, init?: RequestInit & { query?: Record<string, string | number | undefined> }): Promise<T>;
|
||||||
|
raw(url: string, init?: RequestInit): Promise<Response>;
|
||||||
|
}
|
||||||
|
export interface PluginContext {
|
||||||
|
http: HttpClient;
|
||||||
|
log: (msg: string) => void;
|
||||||
|
config: Record<string, unknown>;
|
||||||
|
credentials: { get(): Promise<Record<string, string> | null>; save(c: Record<string, string>): Promise<void> };
|
||||||
|
cursor: { get(): Promise<string | null>; set(c: string): Promise<void> };
|
||||||
|
now(): Date;
|
||||||
|
signal: AbortSignal;
|
||||||
|
}
|
||||||
|
export interface DeviceAuth {
|
||||||
|
start(ctx: PluginContext): Promise<{ userCode: string; verificationUrl: string; deviceCode: string; intervalSec: number; expiresInSec: number }>;
|
||||||
|
poll(ctx: PluginContext, deviceCode: string): Promise<"pending" | Record<string, string>>;
|
||||||
|
}
|
||||||
|
export interface SourcePlugin {
|
||||||
|
manifest: PluginManifest;
|
||||||
|
deviceAuth?: DeviceAuth;
|
||||||
|
pull?(ctx: PluginContext): AsyncIterable<SourceRecord>;
|
||||||
|
pullChanges?(ctx: PluginContext): AsyncIterable<SourceRecord>;
|
||||||
|
lookup?(ctx: PluginContext, q: { title: string; mediaType?: MediaType; year?: number }): Promise<WorkHint[]>;
|
||||||
|
importFile?(ctx: PluginContext, file: { name: string; bytes: Uint8Array }): AsyncIterable<SourceRecord>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function definePlugin(p: SourcePlugin): SourcePlugin {
|
||||||
|
if (p.manifest.sdkVersion !== 1) throw new Error(`plugin "${p.manifest.id}": unsupported sdkVersion ${String(p.manifest.sdkVersion)}`);
|
||||||
|
if (typeof p.manifest.id !== "string" || p.manifest.id.trim() === "") throw new Error("plugin manifest.id must be a non-empty string");
|
||||||
|
return p;
|
||||||
|
}
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
import { describe, expect, test } from "bun:test";
|
||||||
|
import { parseCsv, toCsv } from "../src/csv.ts";
|
||||||
|
|
||||||
|
describe("csv codec", () => {
|
||||||
|
test("round-trips awkward cells", () => {
|
||||||
|
const x = [["a", "b,c", 'q"uote', "line\nbreak", "cr\r\nlf", "", "🐉"], ["", "", "", "", "", "", ""], ["only"]];
|
||||||
|
expect(parseCsv(toCsv(x))).toEqual(x);
|
||||||
|
});
|
||||||
|
test("toCsv uses CRLF and quotes as needed", () => {
|
||||||
|
expect(toCsv([["a", "b c"], ["x,y", 'z"']])).toBe('a,b c\r\n"x,y","z"""\r\n');
|
||||||
|
});
|
||||||
|
test("strips BOM and detects semicolon / tab", () => {
|
||||||
|
expect(parseCsv("a;b\n1;2\n")).toEqual([["a", "b"], ["1", "2"]]);
|
||||||
|
expect(parseCsv("a\tb\n1\t2")).toEqual([["a", "b"], ["1", "2"]]);
|
||||||
|
});
|
||||||
|
test("explicit delimiter wins; delimiters inside quoted header are ignored", () => {
|
||||||
|
expect(parseCsv('"a;b;c",d\n1,2', {})).toEqual([["a;b;c", "d"], ["1", "2"]]);
|
||||||
|
expect(parseCsv("a,b;c\n1,2;3", { delimiter: ";" })).toEqual([["a,b", "c"], ["1,2", "3"]]);
|
||||||
|
});
|
||||||
|
test("handles CRLF, blank trailing lines, and empty input", () => {
|
||||||
|
expect(parseCsv("a,b\r\n1,2\r\n\r\n")).toEqual([["a", "b"], ["1", "2"]]);
|
||||||
|
expect(parseCsv("")).toEqual([]);
|
||||||
|
});
|
||||||
|
});
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user