runSync snapshots and diffs source records, applies new records and never-edited fields, files pending changed/conflict/removed/merged events, de-duplicates plays across sources and ingests listens. listChanges/resolveChange drive the Changes inbox. Co-Authored-By: Claude Opus 5.5 <[email protected]>
111 lines
3.9 KiB
TypeScript
111 lines
3.9 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;
|
|
}
|