library: a sync drops the games the account no longer owns

A sync is the whole of what the account owns, but it only ever upserted,
so a revoked licence or a library from a previous Steam account stayed
listed forever. A sync that fully lands now soft-deletes the rest; an
empty list prunes nothing, and a game owned again comes back.
This commit is contained in:
Wanjohi
2026-10-08 12:19:05 +03:00
parent 4445f4836e
commit ba9191825c
3 changed files with 106 additions and 2 deletions
+7
View File
@@ -70,6 +70,7 @@ export namespace LibraryApi {
z.object({
gamesSynced: z.number(),
libraryEntries: z.number(),
libraryRemoved: z.number().meta({ description: 'Entries dropped because the account no longer owns them' }),
failedEntries: z.array(z.number())
})
)
@@ -200,10 +201,16 @@ export namespace LibraryApi {
}
}
// Only after a sync that fully landed: a game that failed to
// write would otherwise be pruned as if the account lost it.
const libraryRemoved =
failedSteamIDs.size === 0 ? await Library.prune({ userId, keep: gameIds }) : 0;
return c.json({
data: {
gamesSynced,
libraryEntries,
libraryRemoved,
failedEntries: Array.from(failedSteamIDs)
}
});
+65
View File
@@ -0,0 +1,65 @@
import { afterAll, describe, expect, test } from 'bun:test';
import { Fixtures } from '../db/fixtures.js';
import { testDb } from '../db/test.js';
import { Game } from '../game/index.js';
import { Identifier } from '../id.js';
import { Library } from './library.js';
const sql = testDb();
const createdUserIds: string[] = [];
const createdGameIds: string[] = [];
async function newGame(steamAppId: number): Promise<string> {
const [row] = await Game.upsert({
id: Identifier.ascending('game'),
steamAppId,
slug: `library-test-${steamAppId}`,
name: `Library Test ${steamAppId}`
});
if (!row) throw new Error('expected a game row');
createdGameIds.push(row.id);
return row.id;
}
async function own(userId: string, gameId: string) {
await Library.upsert({ id: Identifier.ascending('userLibrary'), userId, gameId });
}
afterAll(async () => {
if (createdUserIds.length > 0) {
await sql`delete from "user_library" where user_id in ${sql(createdUserIds)}`;
await sql`delete from "user" where id in ${sql(createdUserIds)}`;
}
if (createdGameIds.length > 0) {
await sql`delete from "game" where id in ${sql(createdGameIds)}`;
}
});
describe('Library.prune', () => {
test('drops what the account no longer owns, and brings it back when owned again', async () => {
const owner = await Fixtures.owner('library-prune');
createdUserIds.push(owner.userId);
const kept = await newGame(9_100_001);
const lost = await newGame(9_100_002);
await own(owner.userId, kept);
await own(owner.userId, lost);
expect(await Library.prune({ userId: owner.userId, keep: [kept] })).toBe(1);
const after = (await Library.listByUser(owner.userId)).map((e) => e.gameId);
expect(after).toEqual([kept]);
await own(owner.userId, lost);
expect((await Library.listByUser(owner.userId)).length).toBe(2);
});
test('an empty sync removes nothing', async () => {
const owner = await Fixtures.owner('library-empty');
createdUserIds.push(owner.userId);
await own(owner.userId, await newGame(9_100_003));
expect(await Library.prune({ userId: owner.userId, keep: [] })).toBe(0);
expect((await Library.listByUser(owner.userId)).length).toBe(1);
});
});
+34 -2
View File
@@ -1,4 +1,4 @@
import { eq, and, isNull, sql, inArray } from 'drizzle-orm';
import { eq, and, isNull, sql, inArray, notInArray } from 'drizzle-orm';
import z from 'zod';
import { Database } from '../db/index.js';
@@ -86,7 +86,9 @@ export namespace Library {
set: {
playtime2w: input.playtime2w ?? null,
playtimeForever: input.playtimeForever ?? null,
lastPlayed: input.lastPlayed ? new Date(input.lastPlayed) : null
lastPlayed: input.lastPlayed ? new Date(input.lastPlayed) : null,
// Owned again after a prune: the entry comes back.
timeDeleted: null
}
});
});
@@ -212,6 +214,36 @@ export namespace Library {
}
);
/**
* Drop the entries a sync no longer lists.
*
* A sync is the whole of what the account owns, so an entry it leaves out
* is a game the account lost: a licence revoked, or a library from a Steam
* account this user no longer signs in with. An empty `keep` removes
* nothing, because an empty answer from Steam is likelier a blip than an
* account that owns no games.
*/
export const prune = fn(
z.object({ userId: z.string(), keep: z.array(z.string()) }),
async (input) => {
if (input.keep.length === 0) return 0;
return Database.use(async (tx) => {
const removed = await tx
.update(UserLibraryTable)
.set({ timeDeleted: sql`now()` })
.where(
and(
eq(UserLibraryTable.userId, input.userId),
isNull(UserLibraryTable.timeDeleted),
notInArray(UserLibraryTable.gameId, input.keep)
)
)
.returning({ id: UserLibraryTable.id });
return removed.length;
});
}
);
export const remove = fn(Info.shape.id, async (id) => {
await Database.use(async (tx) => {
await tx