mirror of
https://github.com/nestriness/nestri.git
synced 2026-11-03 22:22:39 +02:00
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:
@@ -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)
|
||||
}
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user