Files
Lucas WintherandClaude Opus 5.5 a003c3eb7d fix(core): sync review round 1: lists, supersede, dedupe conflicts, retries
- dedupe files conflicts for disagreeing fields instead of dropping them
- merged-away records never file removals
- matched work committed before apply so retries reuse it (no draft leaks)
- stale pending events superseded by newer values
- multi-source engagement removal only detaches the source
- list records applied with history-derived ownership
- gone targets resolve as ignored; counts only for committed records

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-30 20:43:08 +01:00

115 lines
4.1 KiB
TypeScript

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 } };
}