feat(kobo): add device sync history tracking

Records success and failure events for Kobo device syncs in the database, including operation duration, event types, and error details. Surfaces the recent sync history in the Kobo Settings UI for users to monitor their device activity.
This commit is contained in:
neon
2026-07-03 00:54:01 +00:00
parent 13be42792d
commit 6fdd2569e1
22 changed files with 14601 additions and 119 deletions
@@ -0,0 +1,20 @@
CREATE TABLE "kobo_sync_history" (
"id" serial PRIMARY KEY NOT NULL,
"user_id" integer NOT NULL,
"device_id" integer,
"event" varchar(32) NOT NULL,
"status" varchar(16) NOT NULL,
"counts" jsonb DEFAULT '{}'::jsonb NOT NULL,
"duration_ms" integer NOT NULL,
"error_class" varchar(128),
"error" varchar(500),
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
CONSTRAINT "kobo_sync_history_event_chk" CHECK ("kobo_sync_history"."event" in ('library_sync', 'book_download', 'progress_update', 'annotations_pull', 'annotations_push')),
CONSTRAINT "kobo_sync_history_status_chk" CHECK ("kobo_sync_history"."status" in ('success', 'failed')),
CONSTRAINT "kobo_sync_history_duration_nonnegative_chk" CHECK ("kobo_sync_history"."duration_ms" >= 0)
);
--> statement-breakpoint
ALTER TABLE "kobo_sync_history" ADD CONSTRAINT "kobo_sync_history_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "kobo_sync_history" ADD CONSTRAINT "kobo_sync_history_device_id_kobo_devices_id_fk" FOREIGN KEY ("device_id") REFERENCES "public"."kobo_devices"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint
CREATE INDEX "kobo_sync_history_user_created_idx" ON "kobo_sync_history" USING btree ("user_id","created_at");--> statement-breakpoint
CREATE INDEX "kobo_sync_history_device_created_idx" ON "kobo_sync_history" USING btree ("device_id","created_at");
File diff suppressed because it is too large Load Diff
@@ -253,6 +253,13 @@
"when": 1783009954135,
"tag": "0035_add_koreader_unmatched_book_devices",
"breakpoints": true
},
{
"idx": 36,
"version": "7",
"when": 1783034396046,
"tag": "0036_kobo-sync-history",
"breakpoints": true
}
]
}
+30
View File
@@ -137,6 +137,34 @@ export const koboReadingStates = pgTable(
(t) => [unique().on(t.userId, t.bookId)],
);
export const koboSyncHistory = pgTable(
'kobo_sync_history',
{
id: serial('id').primaryKey(),
userId: integer('user_id')
.notNull()
.references(() => users.id, { onDelete: 'cascade' }),
deviceId: integer('device_id').references(() => koboDevices.id, { onDelete: 'set null' }),
event: varchar('event', { length: 32 }).notNull(),
status: varchar('status', { length: 16 }).notNull(),
counts: jsonb('counts').notNull().default({}),
durationMs: integer('duration_ms').notNull(),
errorClass: varchar('error_class', { length: 128 }),
error: varchar('error', { length: 500 }),
createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull(),
},
(t) => [
index('kobo_sync_history_user_created_idx').on(t.userId, t.createdAt),
index('kobo_sync_history_device_created_idx').on(t.deviceId, t.createdAt),
check(
'kobo_sync_history_event_chk',
sql`${t.event} in ('library_sync', 'book_download', 'progress_update', 'annotations_pull', 'annotations_push')`,
),
check('kobo_sync_history_status_chk', sql`${t.status} in ('success', 'failed')`),
check('kobo_sync_history_duration_nonnegative_chk', sql`${t.durationMs} >= 0`),
],
);
export type KoboDevice = typeof koboDevices.$inferSelect;
export type NewKoboDevice = typeof koboDevices.$inferInsert;
@@ -145,3 +173,5 @@ export type KoboLibrarySnapshot = typeof koboLibrarySnapshots.$inferSelect;
export type KoboSnapshotBook = typeof koboSnapshotBooks.$inferSelect;
export type KoboBookEntitlement = typeof koboBookEntitlements.$inferSelect;
export type KoboReadingState = typeof koboReadingStates.$inferSelect;
export type KoboSyncHistory = typeof koboSyncHistory.$inferSelect;
export type NewKoboSyncHistory = typeof koboSyncHistory.$inferInsert;
@@ -56,6 +56,7 @@ describe('Architecture boundaries', () => {
'src/modules/kobo/services/kobo-download.service.ts',
'src/modules/kobo/services/kobo-reading-state.service.ts',
'src/modules/kobo/services/kobo-settings.service.ts',
'src/modules/kobo/services/kobo-sync-history.service.ts',
'src/modules/kobo/services/kobo-sync.service.ts',
'src/modules/koreader/koreader-chapter-extractor.service.ts',
'src/modules/metadata/metadata.service.ts',
@@ -26,6 +26,11 @@ describe('KoboDeviceController', () => {
resolveBookIdByEntitlementId: vi.fn(),
resolveBookIdByCoverImageId: vi.fn(),
};
const historyService = {
recordSuccess: vi.fn(),
recordFailure: vi.fn(),
countsForBook: vi.fn(),
};
const controller = new KoboDeviceController(
thumbnailService as never,
@@ -33,10 +38,16 @@ describe('KoboDeviceController', () => {
proxyService as never,
analyticsService as never,
bookIdentityService as never,
historyService as never,
);
beforeEach(() => {
vi.clearAllMocks();
historyService.countsForBook.mockImplementation((_userId: number, bookId: number, counts: Record<string, unknown>) => ({
...counts,
bookId,
bookTitle: 'Dune',
}));
bookIdentityService.resolveBookIdByEntitlementId.mockImplementation((_userId: number, id: string) => (/^\d+$/.test(id) ? Number(id) : null));
bookIdentityService.resolveBookIdByCoverImageId.mockImplementation((_userId: number, id: string) => {
const match = /^(\d+)(?:-\d+)?$/.exec(id);
@@ -102,9 +113,31 @@ describe('KoboDeviceController', () => {
const req = { method: 'GET', url: '/api/v1/kobo/token/v1/books/9/download' };
const reply = makeReply();
await controller.download('9', { id: 11 } as never, { deviceToken: 'dev-token' } as never, req as never, reply as never);
await controller.download('9', { id: 11 } as never, { deviceId: 4, deviceToken: 'dev-token' } as never, req as never, reply as never);
expect(downloadService.streamBook).toHaveBeenCalledWith(11, 9, reply);
expect(historyService.recordSuccess).toHaveBeenCalledWith(
expect.objectContaining({
userId: 11,
deviceId: 4,
event: 'book_download',
counts: { downloads: 1, bookId: 9, bookTitle: 'Dune' },
}),
);
expect(historyService.countsForBook).toHaveBeenCalledWith(11, 9, { downloads: 1 });
});
it('records failed downloads before rethrowing', async () => {
const error = new Error('file missing');
const req = { method: 'GET', url: '/api/v1/kobo/token/v1/books/9/download' };
const reply = makeReply();
downloadService.streamBook.mockRejectedValueOnce(error);
await expect(
controller.download('9', { id: 11 } as never, { deviceId: 4, deviceToken: 'dev-token' } as never, req as never, reply as never),
).rejects.toThrow('file missing');
expect(historyService.recordFailure).toHaveBeenCalledWith(expect.objectContaining({ userId: 11, deviceId: 4, event: 'book_download' }), error);
});
it('streams downloads and thumbnails for resolved Kobo UUID ids', async () => {
@@ -14,6 +14,7 @@ import { KoboDownloadService } from './services/kobo-download.service';
import { KoboProxyService } from './services/kobo-proxy.service';
import type { KoboAnalyticsBody } from './kobo-analytics.types';
import { KoboBookIdentityService } from './services/kobo-book-identity.service';
import { KoboSyncHistoryService } from './services/kobo-sync-history.service';
@Controller('kobo/:deviceToken')
@Public()
@@ -27,6 +28,7 @@ export class KoboDeviceController {
private readonly proxyService: KoboProxyService,
private readonly analyticsService: KoboAnalyticsService,
private readonly bookIdentityService: KoboBookIdentityService,
private readonly historyService: KoboSyncHistoryService,
) {}
@Get('v1/books/:bookId/thumbnail/:width/:height/:quality/:isGreyscale/image.jpg')
@@ -75,7 +77,28 @@ export class KoboDeviceController {
) {
const id = await this.bookIdentityService.resolveBookIdByEntitlementId(user.id, bookId);
if (id === null) return this.proxyService.forward(req, reply, device.deviceToken);
await this.downloadService.streamBook(user.id, id, reply);
const startedAt = Date.now();
try {
await this.downloadService.streamBook(user.id, id, reply);
await this.historyService.recordSuccess({
userId: user.id,
deviceId: device.deviceId,
event: 'book_download',
durationMs: Date.now() - startedAt,
counts: await this.historyService.countsForBook(user.id, id, { downloads: 1 }),
});
} catch (error: unknown) {
await this.historyService.recordFailure(
{
userId: user.id,
deviceId: device.deviceId,
event: 'book_download',
durationMs: Date.now() - startedAt,
},
error,
);
throw error;
}
}
@Get('v1/affiliate')
@@ -0,0 +1,146 @@
import { HttpStatus } from '@nestjs/common';
import { KoboReadingServicesController } from './kobo-reading-services.controller';
function makeReply() {
return {
raw: { once: vi.fn() },
status: vi.fn().mockReturnThis(),
header: vi.fn().mockReturnThis(),
send: vi.fn().mockReturnThis(),
};
}
describe('KoboReadingServicesController', () => {
const exchangeService = {
getContentAnnotations: vi.fn(),
markServedSeen: vi.fn(),
patchContentAnnotations: vi.fn(),
getChangedContentIds: vi.fn(),
};
const historyService = {
recordSuccess: vi.fn(),
recordFailure: vi.fn(),
countsForBook: vi.fn(),
};
const controller = new KoboReadingServicesController(exchangeService as never, historyService as never);
beforeEach(() => {
vi.clearAllMocks();
historyService.countsForBook.mockImplementation((_userId: number, bookId: number, counts: Record<string, unknown>) => ({
...counts,
bookId,
bookTitle: 'Dune',
}));
});
it('records successful annotation pulls and sends the response', async () => {
const servedAck = { entries: [], tombstoneStateIds: [12] };
const response = { annotations: [{ id: 'a1' }], nextPageOffsetToken: null };
exchangeService.getContentAnnotations.mockResolvedValue({
bookId: 42,
etag: '"abc"',
notModified: false,
response,
servedAck,
servedCount: 1,
tombstoneCount: 1,
});
const reply = makeReply();
await controller.getAnnotations('content-1', undefined, { id: 8 } as never, { deviceId: 5 } as never, reply as never);
expect(exchangeService.getContentAnnotations).toHaveBeenCalledWith(8, 'content-1', 5, undefined);
expect(historyService.recordSuccess).toHaveBeenCalledWith(
expect.objectContaining({
userId: 8,
deviceId: 5,
event: 'annotations_pull',
counts: { served: 1, tombstones: 1, notModified: false, bookId: 42, bookTitle: 'Dune' },
}),
);
expect(historyService.countsForBook).toHaveBeenCalledWith(8, 42, { served: 1, tombstones: 1, notModified: false });
expect(reply.header).toHaveBeenCalledWith('ETag', '"abc"');
expect(reply.raw.once).toHaveBeenCalledWith('finish', expect.any(Function));
expect(reply.send).toHaveBeenCalledWith(response);
});
it('records not-modified annotation pulls', async () => {
exchangeService.getContentAnnotations.mockResolvedValue({
bookId: 42,
etag: '"abc"',
notModified: true,
servedAck: { entries: [], tombstoneStateIds: [] },
servedCount: 0,
tombstoneCount: 0,
});
const reply = makeReply();
await controller.getAnnotations('content-1', 'W/"0"', { id: 8 } as never, { deviceId: 5 } as never, reply as never);
expect(historyService.recordSuccess).toHaveBeenCalledWith(
expect.objectContaining({
event: 'annotations_pull',
counts: { served: 0, tombstones: 0, notModified: true, bookId: 42, bookTitle: 'Dune' },
}),
);
expect(reply.status).toHaveBeenCalledWith(HttpStatus.NOT_MODIFIED);
expect(reply.send).toHaveBeenCalledWith();
});
it('records failed annotation pulls before rethrowing', async () => {
const error = new Error('annotation lookup failed');
exchangeService.getContentAnnotations.mockRejectedValueOnce(error);
const reply = makeReply();
await expect(controller.getAnnotations('content-1', undefined, { id: 8 } as never, { deviceId: 5 } as never, reply as never)).rejects.toThrow(
'annotation lookup failed',
);
expect(historyService.recordFailure).toHaveBeenCalledWith(expect.objectContaining({ userId: 8, deviceId: 5, event: 'annotations_pull' }), error);
expect(reply.send).not.toHaveBeenCalled();
});
it('records successful annotation pushes', async () => {
exchangeService.patchContentAnnotations.mockResolvedValue({
bookId: 42,
created: 2,
updated: 1,
unchanged: 3,
deleted: 1,
kepubReady: true,
});
await controller.patchAnnotations('content-1', { annotations: [] }, { id: 8 } as never, { deviceId: 5 } as never);
expect(exchangeService.patchContentAnnotations).toHaveBeenCalledWith(8, 'content-1', { annotations: [] }, 5);
expect(historyService.recordSuccess).toHaveBeenCalledWith(
expect.objectContaining({
userId: 8,
deviceId: 5,
event: 'annotations_push',
counts: { created: 2, updated: 1, unchanged: 3, deleted: 1, kepubReady: true, bookId: 42, bookTitle: 'Dune' },
}),
);
expect(historyService.countsForBook).toHaveBeenCalledWith(8, 42, { created: 2, updated: 1, unchanged: 3, deleted: 1, kepubReady: true });
});
it('records failed annotation pushes before rethrowing', async () => {
const error = new Error('annotation write failed');
exchangeService.patchContentAnnotations.mockRejectedValueOnce(error);
await expect(controller.patchAnnotations('content-1', {}, { id: 8 } as never, { deviceId: 5 } as never)).rejects.toThrow(
'annotation write failed',
);
expect(historyService.recordFailure).toHaveBeenCalledWith(expect.objectContaining({ userId: 8, deviceId: 5, event: 'annotations_push' }), error);
});
it('delegates changed content and storage metadata without history rows', async () => {
exchangeService.getChangedContentIds.mockResolvedValue(['book-1']);
await expect(controller.checkForChanges({ id: 8 } as never, { deviceId: 5 } as never)).resolves.toEqual(['book-1']);
expect(controller.getUserStorageMetadata()).toEqual({ continuationToken: null, metadata: [] });
expect(historyService.recordSuccess).not.toHaveBeenCalled();
});
});
@@ -8,12 +8,16 @@ import { KoboDevice } from './decorators/kobo-device.decorator';
import type { KoboDeviceContext } from './guards/kobo-token.guard';
import { KoboTokenGuard } from './guards/kobo-token.guard';
import { KoboAnnotationExchangeService } from './services/kobo-annotation-exchange.service';
import { KoboSyncHistoryService } from './services/kobo-sync-history.service';
@Controller(['kobo/:deviceToken', ''])
@Public()
@UseGuards(KoboTokenGuard)
export class KoboReadingServicesController {
constructor(private readonly exchangeService: KoboAnnotationExchangeService) {}
constructor(
private readonly exchangeService: KoboAnnotationExchangeService,
private readonly historyService: KoboSyncHistoryService,
) {}
@Get('api/v3/content/:contentId/annotations')
async getAnnotations(
@@ -23,7 +27,33 @@ export class KoboReadingServicesController {
@KoboDevice() device: KoboDeviceContext,
@Res() reply: FastifyReply,
) {
const result = await this.exchangeService.getContentAnnotations(user.id, contentId, device.deviceId, ifNoneMatch);
const startedAt = Date.now();
let result: Awaited<ReturnType<KoboAnnotationExchangeService['getContentAnnotations']>>;
try {
result = await this.exchangeService.getContentAnnotations(user.id, contentId, device.deviceId, ifNoneMatch);
await this.historyService.recordSuccess({
userId: user.id,
deviceId: device.deviceId,
event: 'annotations_pull',
durationMs: Date.now() - startedAt,
counts: await this.historyService.countsForBook(user.id, result.bookId, {
served: result.servedCount,
tombstones: result.tombstoneCount,
notModified: result.notModified,
}),
});
} catch (error: unknown) {
await this.historyService.recordFailure(
{
userId: user.id,
deviceId: device.deviceId,
event: 'annotations_pull',
durationMs: Date.now() - startedAt,
},
error,
);
throw error;
}
reply.header('ETag', result.etag);
// This Kobo firmware never echoes our ETag back (it always sends If-None-Match: W/"0" and drives
@@ -50,7 +80,34 @@ export class KoboReadingServicesController {
@CurrentUser() user: RequestUser,
@KoboDevice() device: KoboDeviceContext,
) {
await this.exchangeService.patchContentAnnotations(user.id, contentId, body, device.deviceId);
const startedAt = Date.now();
try {
const result = await this.exchangeService.patchContentAnnotations(user.id, contentId, body, device.deviceId);
await this.historyService.recordSuccess({
userId: user.id,
deviceId: device.deviceId,
event: 'annotations_push',
durationMs: Date.now() - startedAt,
counts: await this.historyService.countsForBook(user.id, result.bookId, {
created: result.created,
updated: result.updated,
unchanged: result.unchanged,
deleted: result.deleted,
kepubReady: result.kepubReady,
}),
});
} catch (error: unknown) {
await this.historyService.recordFailure(
{
userId: user.id,
deviceId: device.deviceId,
event: 'annotations_push',
durationMs: Date.now() - startedAt,
},
error,
);
throw error;
}
}
@Post('api/v3/content/checkforchanges')
@@ -29,6 +29,11 @@ describe('KoboSyncController', () => {
const bookIdentityService = {
resolveBookIdByEntitlementId: vi.fn(),
};
const historyService = {
recordSuccess: vi.fn(),
recordFailure: vi.fn(),
countsForBook: vi.fn(),
};
const controller = new KoboSyncController(
settingsService as never,
@@ -36,11 +41,17 @@ describe('KoboSyncController', () => {
readingStateService as never,
proxyService as never,
bookIdentityService as never,
historyService as never,
);
beforeEach(() => {
vi.clearAllMocks();
bookIdentityService.resolveBookIdByEntitlementId.mockImplementation((_userId: number, id: string) => (/^\d+$/.test(id) ? Number(id) : null));
historyService.countsForBook.mockImplementation((_userId: number, bookId: number, counts: Record<string, unknown>) => ({
...counts,
bookId,
bookTitle: 'Dune',
}));
});
it('initialization builds resource URLs from forwarded headers', () => {
@@ -183,15 +194,42 @@ describe('KoboSyncController', () => {
};
const reply = makeReply();
await controller.librarySync({ deviceToken: 'token-9' } as never, { id: 21 } as never, 'old-sync', req as never, reply as never);
await controller.librarySync({ deviceId: 9, deviceToken: 'token-9' } as never, { id: 21 } as never, 'old-sync', req as never, reply as never);
expect(settingsService.getSettings).not.toHaveBeenCalled();
expect(syncService.getDelta).toHaveBeenCalledWith(21, 'token-9', 'http://kobo.local:3000');
expect(historyService.recordSuccess).toHaveBeenCalledWith(
expect.objectContaining({
userId: 21,
deviceId: 9,
event: 'library_sync',
counts: { entitlements: 1, hasMore: true },
}),
);
expect(reply.header).toHaveBeenCalledWith('x-kobo-sync', 'continue');
expect(reply.header).toHaveBeenCalledWith('x-kobo-synctoken', 'SYNC-2');
expect(reply.send).toHaveBeenCalledWith([{ NewEntitlement: { BookEntitlement: { Id: '12' } } }]);
});
it('records failed library sync operations before rethrowing', async () => {
const error = new Error('snapshot failed');
syncService.getDelta.mockRejectedValue(error);
const req = {
headers: { host: 'kobo.local' },
protocol: 'http',
hostname: 'kobo.local',
socket: { localPort: 3000 },
};
const reply = makeReply();
await expect(
controller.librarySync({ deviceId: 10, deviceToken: 'token-10' } as never, { id: 22 } as never, undefined, req as never, reply as never),
).rejects.toThrow('snapshot failed');
expect(historyService.recordFailure).toHaveBeenCalledWith(expect.objectContaining({ userId: 22, deviceId: 10, event: 'library_sync' }), error);
expect(reply.send).not.toHaveBeenCalled();
});
it('acknowledges delete-items requests for synced BookOrbit collection tags', async () => {
const req = { method: 'POST', url: '/api/v1/kobo/token/v1/library/tags/col-1/items/delete' };
const reply = makeReply();
@@ -299,7 +337,7 @@ describe('KoboSyncController', () => {
CurrentBookmark: { ProgressPercent: 10 },
},
{ id: 8 } as never,
{ deviceToken: 'dev77' } as never,
{ deviceId: 77, deviceToken: 'dev77' } as never,
{ method: 'PUT', url: '/api/v1/kobo/dev77/v1/library/77/state' } as never,
reply as never,
);
@@ -312,6 +350,43 @@ describe('KoboSyncController', () => {
99,
true,
);
expect(historyService.recordSuccess).toHaveBeenCalledWith(
expect.objectContaining({
userId: 8,
deviceId: 77,
event: 'progress_update',
counts: { progressUpdates: 1, twoWayProgressSync: true, bookId: 77, bookTitle: 'Dune' },
}),
);
expect(historyService.countsForBook).toHaveBeenCalledWith(8, 77, { progressUpdates: 1, twoWayProgressSync: true });
expect(reply.send).toHaveBeenCalledWith({ RequestResult: 'Success' });
});
it('records failed reading-state updates before rethrowing', async () => {
const error = new Error('state failed');
settingsService.getSettings.mockResolvedValue({
readingThreshold: 1,
finishedThreshold: 99,
convertToKepub: true,
forceEnableHyphenation: false,
kepubConversionLimitMb: 100,
twoWayProgressSync: true,
});
readingStateService.upsertState.mockRejectedValue(error);
const reply = makeReply();
await expect(
controller.updateReadingState(
'77',
{ CurrentBookmark: { ProgressPercent: 56 } },
{ id: 8 } as never,
{ deviceId: 77, deviceToken: 'dev77' } as never,
{ method: 'PUT', url: '/api/v1/kobo/dev77/v1/library/77/state' } as never,
reply as never,
),
).rejects.toThrow('state failed');
expect(historyService.recordFailure).toHaveBeenCalledWith(expect.objectContaining({ userId: 8, deviceId: 77, event: 'progress_update' }), error);
expect(reply.send).not.toHaveBeenCalled();
});
});
+59 -13
View File
@@ -31,6 +31,7 @@ import { KoboReadingStateService } from './services/kobo-reading-state.service';
import { KoboProxyService } from './services/kobo-proxy.service';
import { KOBO_STORE_RESOURCES } from './kobo-store-resources';
import { KoboBookIdentityService } from './services/kobo-book-identity.service';
import { KoboSyncHistoryService } from './services/kobo-sync-history.service';
function readHeaderValue(value: string | string[] | undefined): string | undefined {
return Array.isArray(value) ? value[0] : value;
@@ -85,6 +86,7 @@ export class KoboSyncController {
private readonly readingStateService: KoboReadingStateService,
private readonly proxyService: KoboProxyService,
private readonly bookIdentityService: KoboBookIdentityService,
private readonly historyService: KoboSyncHistoryService,
) {}
@Get('v1/initialization')
@@ -113,9 +115,32 @@ export class KoboSyncController {
@Req() req: FastifyRequest,
@Res() reply: FastifyReply,
) {
const startedAt = Date.now();
this.logger.log(`librarySync: userId=${user.id} syncToken=${incomingToken ?? 'none'}`);
const baseUrl = buildBaseUrl(req);
const { entitlements, hasMore, syncToken } = await this.syncService.getDelta(user.id, device.deviceToken, baseUrl);
let result: { entitlements: unknown[]; hasMore: boolean; syncToken: string };
try {
result = await this.syncService.getDelta(user.id, device.deviceToken, baseUrl);
} catch (error: unknown) {
await this.historyService.recordFailure(
{
userId: user.id,
deviceId: device.deviceId,
event: 'library_sync',
durationMs: Date.now() - startedAt,
},
error,
);
throw error;
}
const { entitlements, hasMore, syncToken } = result;
await this.historyService.recordSuccess({
userId: user.id,
deviceId: device.deviceId,
event: 'library_sync',
durationMs: Date.now() - startedAt,
counts: { entitlements: entitlements.length, hasMore },
});
reply.header('x-kobo-sync', hasMore ? 'continue' : '');
reply.header('x-kobo-synctoken', syncToken);
reply.send(entitlements);
@@ -196,17 +221,38 @@ export class KoboSyncController {
) {
const id = await this.bookIdentityService.resolveBookIdByEntitlementId(user.id, bookId);
if (id === null) return this.proxyService.forward(req, reply, device.deviceToken);
const settings = await this.settingsService.getSettings(user.id);
const states = body.ReadingStates as Record<string, unknown>[] | undefined;
const statePayload = states?.[0] ?? body;
const result = await this.readingStateService.upsertState(
user.id,
id,
statePayload,
settings.readingThreshold,
settings.finishedThreshold,
settings.twoWayProgressSync,
);
reply.send(result);
const startedAt = Date.now();
try {
const settings = await this.settingsService.getSettings(user.id);
const states = body.ReadingStates as Record<string, unknown>[] | undefined;
const statePayload = states?.[0] ?? body;
const result = await this.readingStateService.upsertState(
user.id,
id,
statePayload,
settings.readingThreshold,
settings.finishedThreshold,
settings.twoWayProgressSync,
);
await this.historyService.recordSuccess({
userId: user.id,
deviceId: device.deviceId,
event: 'progress_update',
durationMs: Date.now() - startedAt,
counts: await this.historyService.countsForBook(user.id, id, { progressUpdates: 1, twoWayProgressSync: settings.twoWayProgressSync }),
});
reply.send(result);
} catch (error: unknown) {
await this.historyService.recordFailure(
{
userId: user.id,
deviceId: device.deviceId,
event: 'progress_update',
durationMs: Date.now() - startedAt,
},
error,
);
throw error;
}
}
}
@@ -16,7 +16,10 @@ describe('KoboUserController', () => {
getSettings: vi.fn(),
updateSettings: vi.fn(),
};
const controller = new KoboUserController(deviceService as never, settingsService as never);
const historyService = {
listForUser: vi.fn(),
};
const controller = new KoboUserController(deviceService as never, settingsService as never, historyService as never);
beforeEach(() => {
vi.clearAllMocks();
@@ -70,6 +73,14 @@ describe('KoboUserController', () => {
expect(settingsService.updateSettings).toHaveBeenCalledWith(5, { convertToKepub: false, twoWayProgressSync: true });
});
it('delegates history reads with current user id and limit', async () => {
historyService.listForUser.mockResolvedValue([{ id: 1, event: 'library_sync' }]);
await expect(controller.listHistory({ id: 9 } as never, 15)).resolves.toEqual([{ id: 1, event: 'library_sync' }]);
expect(historyService.listForUser).toHaveBeenCalledWith(9, 15);
});
it('registers auditable metadata for create/rename/remove actions', () => {
const createAudit = Reflect.getMetadata(AUDITABLE_KEY, KoboUserController.prototype.createDevice) as {
action: AuditAction;
@@ -1,5 +1,5 @@
import { Permission, AuditAction, AuditResource } from '@bookorbit/types';
import { Body, Controller, Delete, Get, HttpCode, HttpStatus, Param, ParseIntPipe, Patch, Post } from '@nestjs/common';
import { Body, Controller, DefaultValuePipe, Delete, Get, HttpCode, HttpStatus, Param, ParseIntPipe, Patch, Post, Query } from '@nestjs/common';
import { CurrentUser } from '../../common/decorators/current-user.decorator';
import { RequirePermission } from '../../common/decorators/require-permission.decorator';
@@ -10,6 +10,7 @@ import { RenameDeviceDto } from './dto/rename-device.dto';
import { UpdateSettingsDto } from './dto/update-settings.dto';
import { KoboDeviceService } from './services/kobo-device.service';
import { KoboSettingsService } from './services/kobo-settings.service';
import { KoboSyncHistoryService } from './services/kobo-sync-history.service';
@Controller('kobo')
@RequirePermission(Permission.KoboSync)
@@ -17,6 +18,7 @@ export class KoboUserController {
constructor(
private readonly deviceService: KoboDeviceService,
private readonly settingsService: KoboSettingsService,
private readonly historyService: KoboSyncHistoryService,
) {}
@Get('devices')
@@ -67,4 +69,9 @@ export class KoboUserController {
updateSettings(@Body() dto: UpdateSettingsDto, @CurrentUser() user: RequestUser) {
return this.settingsService.updateSettings(user.id, dto);
}
@Get('history')
listHistory(@CurrentUser() user: RequestUser, @Query('limit', new DefaultValuePipe(20), ParseIntPipe) limit: number) {
return this.historyService.listForUser(user.id, limit);
}
}
@@ -22,6 +22,7 @@ import { KoboProgressBridgeService } from './services/kobo-progress-bridge.servi
import { KoboProxyService } from './services/kobo-proxy.service';
import { KoboReadingStateService } from './services/kobo-reading-state.service';
import { KoboSettingsService } from './services/kobo-settings.service';
import { KoboSyncHistoryService } from './services/kobo-sync-history.service';
import { KoboSyncService } from './services/kobo-sync.service';
import { KoboThumbnailService } from './services/kobo-thumbnail.service';
import { KoboAnalyticsResolverService } from './services/kobo-analytics-resolver.service';
@@ -56,6 +57,7 @@ describe('KoboModule', () => {
KoboAnnotationMaterializerService,
KoboAnnotationExchangeService,
KoboReadingStateService,
KoboSyncHistoryService,
KoboThumbnailService,
KoboDownloadService,
KoboProxyService,
+2
View File
@@ -27,6 +27,7 @@ import { KoboProxyService } from './services/kobo-proxy.service';
import { KoboBookIdentityService } from './services/kobo-book-identity.service';
import { KoboReadingStateService } from './services/kobo-reading-state.service';
import { KoboSettingsService } from './services/kobo-settings.service';
import { KoboSyncHistoryService } from './services/kobo-sync-history.service';
import { KoboSyncService } from './services/kobo-sync.service';
import { KoboThumbnailService } from './services/kobo-thumbnail.service';
import { KoboAnalyticsResolverService } from './services/kobo-analytics-resolver.service';
@@ -58,6 +59,7 @@ import { KoboAnalyticsService } from './services/kobo-analytics.service';
KoboAnnotationMaterializerService,
KoboAnnotationExchangeService,
KoboReadingStateService,
KoboSyncHistoryService,
KoboThumbnailService,
KoboDownloadService,
KoboProxyService,
@@ -29,12 +29,24 @@ export interface ServedAckPlan {
}
export interface KoboContentAnnotationsResult {
bookId: number;
etag: string;
notModified: boolean;
servedCount: number;
tombstoneCount: number;
response?: KoboReadingServiceAnnotationsResponse;
servedAck: ServedAckPlan;
}
export interface KoboPatchAnnotationsResult {
bookId: number;
created: number;
updated: number;
unchanged: number;
deleted: number;
kepubReady: boolean;
}
@Injectable()
export class KoboAnnotationExchangeService {
private readonly logger = new Logger(KoboAnnotationExchangeService.name);
@@ -124,11 +136,16 @@ export class KoboAnnotationExchangeService {
`[${GET_EVENT}] [end] userId=${userId} bookId=${bookId} deviceId=${deviceId} durationMs=${Date.now() - startedAtMs} served=${served.length} tombstones=${servedAck.tombstoneStateIds.length} skippedNoLocation=${skippedNoLocation} notModified=${notModified} - kobo annotations served`,
);
if (notModified) return { etag, notModified: true, servedAck };
if (notModified) {
return { bookId, etag, notModified: true, servedAck, servedCount: served.length, tombstoneCount: servedAck.tombstoneStateIds.length };
}
return {
bookId,
etag,
notModified: false,
servedAck,
servedCount: served.length,
tombstoneCount: servedAck.tombstoneStateIds.length,
response: {
annotations: served.map((row) => toKoboReadingServiceAnnotation(row)),
nextPageOffsetToken: null,
@@ -147,13 +164,13 @@ export class KoboAnnotationExchangeService {
});
}
async patchContentAnnotations(userId: number, contentId: string, body: unknown, deviceId: number): Promise<void> {
async patchContentAnnotations(userId: number, contentId: string, body: unknown, deviceId: number): Promise<KoboPatchAnnotationsResult> {
const startedAtMs = Date.now();
const bookId = await this.resolveBookIdByContentId(userId, contentId);
if (bookId === null) throw new NotFoundException(`Kobo content ${contentId} not found`);
const operations = extractKoboAnnotationOperations(body);
if (operations.length === 0) return;
if (operations.length === 0) return { bookId, created: 0, updated: 0, unchanged: 0, deleted: 0, kepubReady: false };
const upserts = operations.filter((operation): operation is IncomingUpsert => operation.kind === 'upsert');
const deletes = operations.filter((operation) => operation.kind === 'delete');
@@ -193,6 +210,7 @@ export class KoboAnnotationExchangeService {
this.logger.log(
`[${PATCH_EVENT}] [end] userId=${userId} bookId=${bookId} deviceId=${deviceId} durationMs=${Date.now() - startedAtMs} created=${result.created} updated=${result.updated} unchanged=${result.unchanged} deleted=${deletedCount} kepubReady=${kepub.ok} - kobo annotation operations applied`,
);
return { bookId, created: result.created, updated: result.updated, unchanged: result.unchanged, deleted: deletedCount, kepubReady: kepub.ok };
}
async getChangedContentIds(userId: number, deviceId: number): Promise<string[]> {
@@ -0,0 +1,271 @@
import { KoboSyncHistoryService } from './kobo-sync-history.service';
function makeSelectChain(rows: unknown[]) {
const chain = {
from: vi.fn().mockReturnThis(),
leftJoin: vi.fn().mockReturnThis(),
where: vi.fn().mockReturnThis(),
orderBy: vi.fn().mockReturnThis(),
limit: vi.fn().mockResolvedValue(rows),
};
return chain;
}
function makeBookAccessService() {
return {
assertBookAccessible: vi.fn().mockResolvedValue(undefined),
};
}
describe('KoboSyncHistoryService', () => {
it('lists recent history for a user and normalizes response fields', async () => {
const rows = [
{
id: 12,
deviceId: 3,
deviceName: 'Kobo Libra',
event: 'library_sync',
status: 'success',
counts: { entitlements: 4, hasMore: false },
durationMs: 42,
errorClass: null,
error: null,
createdAt: new Date('2026-06-01T10:00:00.000Z'),
},
{
id: 11,
deviceId: null,
deviceName: null,
event: 'book_download',
status: 'failed',
counts: null,
durationMs: 5,
errorClass: 'NotFoundException',
error: 'File not found',
createdAt: new Date('2026-06-01T09:00:00.000Z'),
},
];
const selectChain = makeSelectChain(rows);
const db = { select: vi.fn().mockReturnValue(selectChain) };
const service = new KoboSyncHistoryService(db as never, makeBookAccessService() as never);
await expect(service.listForUser(7, 500)).resolves.toEqual([
{
id: 12,
deviceId: 3,
deviceName: 'Kobo Libra',
event: 'library_sync',
status: 'success',
counts: { entitlements: 4, hasMore: false },
durationMs: 42,
errorClass: null,
error: null,
createdAt: '2026-06-01T10:00:00.000Z',
},
{
id: 11,
deviceId: null,
deviceName: null,
event: 'book_download',
status: 'failed',
counts: {},
durationMs: 5,
errorClass: 'NotFoundException',
error: 'File not found',
createdAt: '2026-06-01T09:00:00.000Z',
},
]);
expect(selectChain.limit).toHaveBeenCalledWith(100);
});
it('applies default limit of 20 when no limit argument is passed', async () => {
const selectChain = makeSelectChain([]);
const db = { select: vi.fn().mockReturnValue(selectChain) };
const service = new KoboSyncHistoryService(db as never, makeBookAccessService() as never);
await service.listForUser(7);
expect(selectChain.limit).toHaveBeenCalledWith(20);
});
it('clamps limit to minimum of 1 when zero is passed', async () => {
const selectChain = makeSelectChain([]);
const db = { select: vi.fn().mockReturnValue(selectChain) };
const service = new KoboSyncHistoryService(db as never, makeBookAccessService() as never);
await service.listForUser(7, 0);
expect(selectChain.limit).toHaveBeenCalledWith(1);
});
it('records successful events and prunes old user history', async () => {
const values = vi.fn().mockResolvedValue(undefined);
const db = {
insert: vi.fn().mockReturnValue({ values }),
execute: vi.fn().mockResolvedValue(undefined),
};
const service = new KoboSyncHistoryService(db as never, makeBookAccessService() as never);
await service.recordSuccess({
userId: 7,
deviceId: 3,
event: 'library_sync',
durationMs: 12.9,
counts: { entitlements: 4, hasMore: false },
});
expect(values).toHaveBeenCalledWith(
expect.objectContaining({
userId: 7,
deviceId: 3,
event: 'library_sync',
status: 'success',
counts: { entitlements: 4, hasMore: false },
durationMs: 12,
errorClass: null,
error: null,
}),
);
expect(db.execute).toHaveBeenCalledTimes(1);
});
it('records failed events with sanitized short error fields', async () => {
const values = vi.fn().mockResolvedValue(undefined);
const db = {
insert: vi.fn().mockReturnValue({ values }),
execute: vi.fn().mockResolvedValue(undefined),
};
const service = new KoboSyncHistoryService(db as never, makeBookAccessService() as never);
await service.recordFailure(
{
userId: 7,
deviceId: 3,
event: 'progress_update',
durationMs: -10,
},
new Error('bad "thing"\\path\nnext line'),
);
expect(values).toHaveBeenCalledWith(
expect.objectContaining({
userId: 7,
deviceId: 3,
event: 'progress_update',
status: 'failed',
counts: {},
durationMs: 0,
errorClass: 'Error',
error: 'bad \\"thing\\"\\\\path next line',
}),
);
});
it('records failed events from non-Error values', async () => {
const values = vi.fn().mockResolvedValue(undefined);
const db = {
insert: vi.fn().mockReturnValue({ values }),
execute: vi.fn().mockResolvedValue(undefined),
};
const service = new KoboSyncHistoryService(db as never, makeBookAccessService() as never);
await service.recordFailure(
{
userId: 7,
deviceId: 3,
event: 'annotations_push',
durationMs: 4,
},
'string failure',
);
expect(values).toHaveBeenCalledWith(
expect.objectContaining({
status: 'failed',
errorClass: 'Error',
error: 'string failure',
}),
);
});
it('does not throw when recording history fails', async () => {
const db = {
insert: vi.fn().mockReturnValue({ values: vi.fn().mockRejectedValue(new Error('db unavailable')) }),
execute: vi.fn(),
};
const service = new KoboSyncHistoryService(db as never, makeBookAccessService() as never);
await expect(
service.recordSuccess({
userId: 7,
deviceId: 3,
event: 'annotations_pull',
durationMs: 1,
}),
).resolves.toBeUndefined();
expect(db.execute).not.toHaveBeenCalled();
});
it('does not throw when recording history fails with a non-Error value', async () => {
const db = {
insert: vi.fn().mockReturnValue({ values: vi.fn().mockRejectedValue('db unavailable') }),
execute: vi.fn(),
};
const service = new KoboSyncHistoryService(db as never, makeBookAccessService() as never);
await expect(
service.recordSuccess({
userId: 7,
deviceId: 3,
event: 'annotations_pull',
durationMs: 1,
}),
).resolves.toBeUndefined();
expect(db.execute).not.toHaveBeenCalled();
});
it('adds book context to history counts', async () => {
const selectChain = makeSelectChain([{ title: 'Dune' }]);
const db = { select: vi.fn().mockReturnValue(selectChain) };
const bookAccessService = makeBookAccessService();
const service = new KoboSyncHistoryService(db as never, bookAccessService as never);
await expect(service.countsForBook(7, 44, { progressUpdates: 1 })).resolves.toEqual({
progressUpdates: 1,
bookId: 44,
bookTitle: 'Dune',
});
expect(bookAccessService.assertBookAccessible).toHaveBeenCalledWith(7, 44);
expect(selectChain.limit).toHaveBeenCalledWith(1);
});
it('keeps a null book title when the book card is not found', async () => {
const selectChain = makeSelectChain([]);
const db = { select: vi.fn().mockReturnValue(selectChain) };
const service = new KoboSyncHistoryService(db as never, makeBookAccessService() as never);
await expect(service.countsForBook(7, 45)).resolves.toEqual({
bookId: 45,
bookTitle: null,
});
});
it('keeps counts when book context lookup is not accessible', async () => {
const db = { select: vi.fn() };
const bookAccessService = {
assertBookAccessible: vi.fn().mockRejectedValue(new Error('not allowed')),
};
const service = new KoboSyncHistoryService(db as never, bookAccessService as never);
await expect(service.countsForBook(7, 45, { downloads: 1 })).resolves.toEqual({
downloads: 1,
bookId: 45,
bookTitle: null,
});
expect(db.select).not.toHaveBeenCalled();
});
});
@@ -0,0 +1,148 @@
import { Inject, Injectable, Logger } from '@nestjs/common';
import { desc, eq, sql } from 'drizzle-orm';
import { NodePgDatabase } from 'drizzle-orm/node-postgres';
import type { KoboSyncHistoryCounts, KoboSyncHistoryEntry, KoboSyncHistoryEvent } from '@bookorbit/types';
import { DB } from '../../../db/db.module';
import * as schema from '../../../db/schema';
import { sanitizeLogValue } from '../../../common/utils/log-sanitize.utils';
import { KoboBookAccessService } from './kobo-book-access.service';
type Db = NodePgDatabase<typeof schema>;
const HISTORY_LIMIT = 100;
const DEFAULT_LIST_LIMIT = 20;
interface RecordBaseInput {
userId: number;
deviceId: number;
event: KoboSyncHistoryEvent;
durationMs: number;
counts?: KoboSyncHistoryCounts;
}
@Injectable()
export class KoboSyncHistoryService {
private readonly logger = new Logger(KoboSyncHistoryService.name);
constructor(
@Inject(DB) private readonly db: Db,
private readonly bookAccessService: KoboBookAccessService,
) {}
async listForUser(userId: number, limit = DEFAULT_LIST_LIMIT): Promise<KoboSyncHistoryEntry[]> {
const cappedLimit = Math.min(Math.max(Math.trunc(limit), 1), HISTORY_LIMIT);
const rows = await this.db
.select({
id: schema.koboSyncHistory.id,
deviceId: schema.koboSyncHistory.deviceId,
deviceName: schema.koboDevices.name,
event: schema.koboSyncHistory.event,
status: schema.koboSyncHistory.status,
counts: schema.koboSyncHistory.counts,
durationMs: schema.koboSyncHistory.durationMs,
errorClass: schema.koboSyncHistory.errorClass,
error: schema.koboSyncHistory.error,
createdAt: schema.koboSyncHistory.createdAt,
})
.from(schema.koboSyncHistory)
.leftJoin(schema.koboDevices, eq(schema.koboDevices.id, schema.koboSyncHistory.deviceId))
.where(eq(schema.koboSyncHistory.userId, userId))
.orderBy(desc(schema.koboSyncHistory.createdAt), desc(schema.koboSyncHistory.id))
.limit(cappedLimit);
return rows.map((row) => ({
id: row.id,
deviceId: row.deviceId,
deviceName: row.deviceName,
event: row.event as KoboSyncHistoryEvent,
status: row.status as KoboSyncHistoryEntry['status'],
counts: this.asCounts(row.counts),
durationMs: row.durationMs,
errorClass: row.errorClass,
error: row.error,
createdAt: row.createdAt.toISOString(),
}));
}
async recordSuccess(input: RecordBaseInput): Promise<void> {
await this.record({
...input,
status: 'success',
errorClass: null,
error: null,
});
}
async recordFailure(input: RecordBaseInput, error: unknown): Promise<void> {
const err = error instanceof Error ? error : new Error(String(error));
await this.record({
...input,
status: 'failed',
errorClass: err.constructor.name,
error: sanitizeLogValue(err.message, 500),
});
}
async countsForBook(userId: number, bookId: number, counts: KoboSyncHistoryCounts = {}): Promise<KoboSyncHistoryCounts> {
let bookTitle: string | null;
try {
await this.bookAccessService.assertBookAccessible(userId, bookId);
const [book] = await this.db
.select({ title: schema.bookMetadata.title })
.from(schema.books)
.leftJoin(schema.bookMetadata, eq(schema.bookMetadata.bookId, schema.books.id))
.where(eq(schema.books.id, bookId))
.limit(1);
bookTitle = book?.title ?? null;
} catch {
bookTitle = null;
}
return {
...counts,
bookId,
bookTitle,
};
}
private async record(input: RecordBaseInput & { status: 'success' | 'failed'; errorClass: string | null; error: string | null }): Promise<void> {
try {
await this.db.insert(schema.koboSyncHistory).values({
userId: input.userId,
deviceId: input.deviceId,
event: input.event,
status: input.status,
counts: input.counts ?? {},
durationMs: Math.max(0, Math.trunc(input.durationMs)),
errorClass: input.errorClass,
error: input.error,
});
await this.pruneUserHistory(input.userId);
} catch (error: unknown) {
const err = error instanceof Error ? error : new Error(String(error));
this.logger.warn(
`[kobo.sync_history] [fail] userId=${input.userId} durationMs=0 errorClass=${err.constructor.name} error="${sanitizeLogValue(err.message)}" - failed to record kobo sync history`,
);
}
}
private async pruneUserHistory(userId: number): Promise<void> {
await this.db.execute(sql`
DELETE FROM ${schema.koboSyncHistory}
WHERE ${schema.koboSyncHistory.userId} = ${userId}
AND ${schema.koboSyncHistory.id} NOT IN (
SELECT ${schema.koboSyncHistory.id}
FROM ${schema.koboSyncHistory}
WHERE ${schema.koboSyncHistory.userId} = ${userId}
ORDER BY ${schema.koboSyncHistory.createdAt} DESC, ${schema.koboSyncHistory.id} DESC
LIMIT ${HISTORY_LIMIT}
)
`);
}
private asCounts(value: unknown): KoboSyncHistoryCounts {
if (!value || typeof value !== 'object' || Array.isArray(value)) return {};
return value as KoboSyncHistoryCounts;
}
}