mirror of
https://github.com/bookorbit/bookorbit.git
synced 2026-11-03 00:11:37 -06:00
fix(kobo): track library snapshots and sync progress per device
Migrate the snapshot and snapshot books tables to be per-device instead of per-user. This resolves a sync race condition where only the first registered Kobo device for a user receives the library delta, leaving the others empty. Closes #314
This commit is contained in:
@@ -793,13 +793,28 @@ const leftColumnProgressRows = computed<ProgressRow[]>(() => {
|
||||
const leftColumnProgressVisible = computed(() => leftColumnProgressRows.value.slice(0, 3))
|
||||
const leftColumnProgressOverflow = computed(() => Math.max(0, leftColumnProgressRows.value.length - 3))
|
||||
|
||||
function formatKoboDeviceNames(snapshots: BookKoboState['snapshots']): string {
|
||||
const names = snapshots.map((snapshot) => snapshot.deviceName)
|
||||
if (names.length === 1) return names.join('')
|
||||
if (names.length === 2) return names.join(' and ')
|
||||
return `${snapshots.length} devices`
|
||||
}
|
||||
|
||||
const koboAnomaly = computed(() => {
|
||||
if (!canViewKobo.value) return null
|
||||
const snap = koboState.value?.snapshot
|
||||
if (!snap) return null
|
||||
if (snap.pendingDelete) return { label: 'Pending delete from device', tooltip: 'Kobo will remove it on next sync.' }
|
||||
if (snap.removedByDevice) return { label: 'Removed by device', tooltip: 'Kobo reported this book removed.' }
|
||||
if (snap.synced === false) return { label: 'Not synced', tooltip: 'Queued for next Kobo sync.' }
|
||||
const snapshots = koboState.value?.snapshots ?? []
|
||||
const pendingDelete = snapshots.filter((snapshot) => snapshot.pendingDelete)
|
||||
if (pendingDelete.length > 0) {
|
||||
return { label: `Pending delete on ${formatKoboDeviceNames(pendingDelete)}`, tooltip: 'Kobo will remove it on the next sync.' }
|
||||
}
|
||||
const removedByDevice = snapshots.filter((snapshot) => snapshot.removedByDevice)
|
||||
if (removedByDevice.length > 0) {
|
||||
return { label: `Removed on ${formatKoboDeviceNames(removedByDevice)}`, tooltip: 'Kobo reported this book removed.' }
|
||||
}
|
||||
const unsynced = snapshots.filter((snapshot) => snapshot.synced === false)
|
||||
if (unsynced.length > 0) {
|
||||
return { label: `Not synced on ${formatKoboDeviceNames(unsynced)}`, tooltip: 'Queued for the next Kobo sync.' }
|
||||
}
|
||||
return null
|
||||
})
|
||||
|
||||
@@ -1035,7 +1050,7 @@ async function loadSupplemental() {
|
||||
eligibleForKoboSync: fallbackSyncCollections.length > 0,
|
||||
syncCollections: fallbackSyncCollections,
|
||||
readingState: null,
|
||||
snapshot: null,
|
||||
snapshots: [],
|
||||
}
|
||||
}
|
||||
} else {
|
||||
@@ -1051,7 +1066,7 @@ async function loadSupplemental() {
|
||||
eligibleForKoboSync: false,
|
||||
syncCollections: [],
|
||||
readingState: null,
|
||||
snapshot: null,
|
||||
snapshots: [],
|
||||
}
|
||||
: null
|
||||
} finally {
|
||||
|
||||
@@ -115,6 +115,9 @@ function mountDetails(book: BookDetail) {
|
||||
Popover: { template: '<div><slot /><slot name="content" /></div>' },
|
||||
PopoverTrigger: { template: '<div><slot /></div>' },
|
||||
PopoverContent: { template: '<div><slot /></div>' },
|
||||
Tooltip: { template: '<div><slot /></div>' },
|
||||
TooltipTrigger: { template: '<div><slot /></div>' },
|
||||
TooltipContent: { template: '<div><slot /></div>' },
|
||||
},
|
||||
},
|
||||
})
|
||||
@@ -146,13 +149,13 @@ describe('DetailsTab cover surface', () => {
|
||||
const url = String(input)
|
||||
if (url.includes('/metadata-score/weights')) return response({})
|
||||
if (url.includes('/audio-progress')) return response(null)
|
||||
if (url.includes('/collections?')) return response([])
|
||||
if (url.includes('/collections/membership')) return response([])
|
||||
if (url.includes('/kobo-state')) {
|
||||
return response({
|
||||
eligibleForKoboSync: false,
|
||||
syncCollections: [],
|
||||
readingState: null,
|
||||
snapshot: null,
|
||||
snapshots: [],
|
||||
})
|
||||
}
|
||||
if (url.includes('/koreader/books/')) return response(null)
|
||||
@@ -266,6 +269,59 @@ describe('DetailsTab cover surface', () => {
|
||||
expect(wrapper.text()).toContain('Author One, Author Two')
|
||||
})
|
||||
|
||||
it('summarizes pending Kobo sync state for each affected device', async () => {
|
||||
mocks.api.mockImplementation(async (input) => {
|
||||
const url = String(input)
|
||||
if (url.includes('/metadata-score/weights')) return response({})
|
||||
if (url.includes('/audio-progress')) return response(null)
|
||||
if (url.includes('/collections/membership')) return response([])
|
||||
if (url.includes('/kobo-state')) {
|
||||
return response({
|
||||
eligibleForKoboSync: true,
|
||||
syncCollections: ['Favorites'],
|
||||
readingState: null,
|
||||
snapshots: [
|
||||
{
|
||||
deviceId: 1,
|
||||
deviceName: 'Libra',
|
||||
snapshotId: 11,
|
||||
snapshotUpdatedAt: '2026-01-01T00:00:00.000Z',
|
||||
inSnapshot: true,
|
||||
synced: false,
|
||||
pendingDelete: true,
|
||||
isNew: false,
|
||||
removedByDevice: false,
|
||||
fileHash: null,
|
||||
metadataHash: null,
|
||||
},
|
||||
{
|
||||
deviceId: 2,
|
||||
deviceName: 'Elipsa',
|
||||
snapshotId: 12,
|
||||
snapshotUpdatedAt: '2026-01-01T00:00:00.000Z',
|
||||
inSnapshot: true,
|
||||
synced: false,
|
||||
pendingDelete: true,
|
||||
isNew: false,
|
||||
removedByDevice: false,
|
||||
fileHash: null,
|
||||
metadataHash: null,
|
||||
},
|
||||
],
|
||||
})
|
||||
}
|
||||
if (url.includes('/koreader/books/')) return response(null)
|
||||
if (url.includes('/progress')) return response([])
|
||||
return response({})
|
||||
})
|
||||
|
||||
const wrapper = mountDetails(makeBook())
|
||||
await flushPromises()
|
||||
|
||||
expect(mocks.api).toHaveBeenCalledWith('/api/v1/books/12/kobo-state')
|
||||
expect(wrapper.text()).toContain('Pending delete on Libra and Elipsa')
|
||||
})
|
||||
|
||||
it('links every series membership to its series detail page', async () => {
|
||||
const wrapper = mountDetails(
|
||||
makeBook({
|
||||
|
||||
@@ -217,6 +217,8 @@ export type BookKoboReadingState = {
|
||||
};
|
||||
|
||||
export type BookKoboSnapshotState = {
|
||||
deviceId: number;
|
||||
deviceName: string;
|
||||
snapshotId: number;
|
||||
snapshotUpdatedAt: string;
|
||||
inSnapshot: boolean;
|
||||
@@ -232,7 +234,7 @@ export type BookKoboState = {
|
||||
eligibleForKoboSync: boolean;
|
||||
syncCollections: string[];
|
||||
readingState: BookKoboReadingState | null;
|
||||
snapshot: BookKoboSnapshotState | null;
|
||||
snapshots: BookKoboSnapshotState[];
|
||||
};
|
||||
|
||||
export type BooksPage = {
|
||||
|
||||
@@ -9,11 +9,7 @@ export const E2E_GLOBAL_CHANGE_PATHS = Object.freeze([
|
||||
"docker/**",
|
||||
]);
|
||||
|
||||
const SHARED_DB_AND_HELPER_PATHS = Object.freeze([
|
||||
"server/src/db/schema/**",
|
||||
"scripts/e2e/**",
|
||||
"scripts/db/**",
|
||||
]);
|
||||
const SHARED_DB_AND_HELPER_PATHS = Object.freeze(["server/src/db/schema/**", "scripts/e2e/**", "scripts/db/**"]);
|
||||
|
||||
const SHARED_AUTH_TYPE_PATHS = Object.freeze(["packages/types/src/auth.ts", "packages/types/src/permissions.ts"]);
|
||||
|
||||
@@ -481,6 +477,25 @@ export const E2E_SUITES = Object.freeze({
|
||||
...SHARED_DB_AND_HELPER_PATHS,
|
||||
],
|
||||
},
|
||||
"kobo-multi-device-sync": {
|
||||
id: "kobo-multi-device-sync",
|
||||
name: "Kobo Multi-Device Sync",
|
||||
timeout: 60,
|
||||
lane: "full",
|
||||
description: "Kobo device-owned library snapshot suite",
|
||||
vitestTarget: "test/kobo-multi-device-sync.e2e-spec.ts",
|
||||
junitOutput: `${TEST_RESULTS_DIR}/kobo-multi-device-sync-e2e-junit.xml`,
|
||||
prepareDedicatedDatabase: true,
|
||||
useDedicatedDatabase: true,
|
||||
changedPaths: [
|
||||
"server/src/modules/kobo/**",
|
||||
"server/src/modules/book/**",
|
||||
"server/test/kobo-multi-device-sync.e2e-spec.ts",
|
||||
"server/test/e2e/reader-state-isolation/**",
|
||||
"packages/types/src/book.ts",
|
||||
...SHARED_DB_AND_HELPER_PATHS,
|
||||
],
|
||||
},
|
||||
"users-admin-lifecycle": {
|
||||
id: "users-admin-lifecycle",
|
||||
name: "Users Admin Lifecycle",
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
ALTER TABLE "kobo_devices" ADD CONSTRAINT "kobo_devices_id_user_id_unique" UNIQUE("id","user_id");
|
||||
@@ -0,0 +1,30 @@
|
||||
CREATE TABLE "kobo_device_library_snapshots" (
|
||||
"id" serial PRIMARY KEY NOT NULL,
|
||||
"user_id" integer NOT NULL,
|
||||
"device_id" integer NOT NULL,
|
||||
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||
"updated_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||
CONSTRAINT "kobo_device_library_snapshots_device_id_unique" UNIQUE("device_id")
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE "kobo_device_snapshot_books" (
|
||||
"snapshot_id" integer NOT NULL,
|
||||
"book_id" integer NOT NULL,
|
||||
"synced" boolean DEFAULT false NOT NULL,
|
||||
"pending_delete" boolean DEFAULT false NOT NULL,
|
||||
"is_new" boolean DEFAULT true NOT NULL,
|
||||
"removed_by_device" boolean DEFAULT false NOT NULL,
|
||||
"needs_legacy_numeric_removal" boolean DEFAULT false NOT NULL,
|
||||
"file_hash" varchar(64),
|
||||
"delivery_hash" varchar(64),
|
||||
"metadata_hash" varchar(64),
|
||||
CONSTRAINT "kobo_device_snapshot_books_snapshot_id_book_id_pk" PRIMARY KEY("snapshot_id","book_id")
|
||||
);
|
||||
--> statement-breakpoint
|
||||
ALTER TABLE "kobo_library_snapshots" ADD COLUMN "legacy_device_cutoff_at" timestamp with time zone DEFAULT now() NOT NULL;--> statement-breakpoint
|
||||
ALTER TABLE "kobo_device_library_snapshots" ADD CONSTRAINT "kobo_device_library_snapshots_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_device_library_snapshots" ADD CONSTRAINT "kobo_device_library_snapshots_device_owner_fk" FOREIGN KEY ("device_id","user_id") REFERENCES "public"."kobo_devices"("id","user_id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "kobo_device_snapshot_books" ADD CONSTRAINT "kobo_device_snapshot_books_snapshot_id_kobo_device_library_snapshots_id_fk" FOREIGN KEY ("snapshot_id") REFERENCES "public"."kobo_device_library_snapshots"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "kobo_device_snapshot_books" ADD CONSTRAINT "kobo_device_snapshot_books_book_id_books_id_fk" FOREIGN KEY ("book_id") REFERENCES "public"."books"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||
CREATE INDEX "kobo_device_library_snapshots_user_id_idx" ON "kobo_device_library_snapshots" USING btree ("user_id");--> statement-breakpoint
|
||||
CREATE INDEX "kobo_device_snapshot_books_snapshot_synced_book_idx" ON "kobo_device_snapshot_books" USING btree ("snapshot_id","synced","book_id");
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -316,6 +316,20 @@
|
||||
"when": 1783575766119,
|
||||
"tag": "0044_add_published_date",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 45,
|
||||
"version": "7",
|
||||
"when": 1783630272168,
|
||||
"tag": "0045_kobo-device-owner-key",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 46,
|
||||
"version": "7",
|
||||
"when": 1783630311448,
|
||||
"tag": "0046_kobo-device-snapshots",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -1,5 +1,20 @@
|
||||
import { sql } from 'drizzle-orm';
|
||||
import { boolean, check, index, integer, jsonb, pgTable, primaryKey, real, serial, timestamp, unique, uuid, varchar } from 'drizzle-orm/pg-core';
|
||||
import {
|
||||
boolean,
|
||||
check,
|
||||
foreignKey,
|
||||
index,
|
||||
integer,
|
||||
jsonb,
|
||||
pgTable,
|
||||
primaryKey,
|
||||
real,
|
||||
serial,
|
||||
timestamp,
|
||||
unique,
|
||||
uuid,
|
||||
varchar,
|
||||
} from 'drizzle-orm/pg-core';
|
||||
|
||||
import { books } from './books';
|
||||
import { users } from './auth';
|
||||
@@ -17,7 +32,11 @@ export const koboDevices = pgTable(
|
||||
lastSeenAt: timestamp('last_seen_at', { withTimezone: true }),
|
||||
createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull(),
|
||||
},
|
||||
(t) => [index('kobo_devices_user_id_idx').on(t.userId), unique('kobo_devices_client_device_id_unique').on(t.clientDeviceId)],
|
||||
(t) => [
|
||||
index('kobo_devices_user_id_idx').on(t.userId),
|
||||
unique('kobo_devices_client_device_id_unique').on(t.clientDeviceId),
|
||||
unique('kobo_devices_id_user_id_unique').on(t.id, t.userId),
|
||||
],
|
||||
);
|
||||
|
||||
export const koboSyncSettings = pgTable(
|
||||
@@ -48,12 +67,13 @@ export const koboSyncSettings = pgTable(
|
||||
],
|
||||
);
|
||||
|
||||
export const koboLibrarySnapshots = pgTable('kobo_library_snapshots', {
|
||||
export const koboLegacyLibrarySnapshots = pgTable('kobo_library_snapshots', {
|
||||
id: serial('id').primaryKey(),
|
||||
userId: integer('user_id')
|
||||
.notNull()
|
||||
.references(() => users.id, { onDelete: 'cascade' })
|
||||
.unique(),
|
||||
legacyDeviceCutoffAt: timestamp('legacy_device_cutoff_at', { withTimezone: true }).defaultNow().notNull(),
|
||||
createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull(),
|
||||
updatedAt: timestamp('updated_at', { withTimezone: true })
|
||||
.defaultNow()
|
||||
@@ -61,12 +81,12 @@ export const koboLibrarySnapshots = pgTable('kobo_library_snapshots', {
|
||||
.$onUpdateFn(() => new Date()),
|
||||
});
|
||||
|
||||
export const koboSnapshotBooks = pgTable(
|
||||
export const koboLegacySnapshotBooks = pgTable(
|
||||
'kobo_snapshot_books',
|
||||
{
|
||||
snapshotId: integer('snapshot_id')
|
||||
.notNull()
|
||||
.references(() => koboLibrarySnapshots.id, { onDelete: 'cascade' }),
|
||||
.references(() => koboLegacyLibrarySnapshots.id, { onDelete: 'cascade' }),
|
||||
bookId: integer('book_id')
|
||||
.notNull()
|
||||
.references(() => books.id, { onDelete: 'cascade' }),
|
||||
@@ -84,6 +104,55 @@ export const koboSnapshotBooks = pgTable(
|
||||
],
|
||||
);
|
||||
|
||||
export const koboLibrarySnapshots = pgTable(
|
||||
'kobo_device_library_snapshots',
|
||||
{
|
||||
id: serial('id').primaryKey(),
|
||||
userId: integer('user_id')
|
||||
.notNull()
|
||||
.references(() => users.id, { onDelete: 'cascade' }),
|
||||
deviceId: integer('device_id').notNull(),
|
||||
createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull(),
|
||||
updatedAt: timestamp('updated_at', { withTimezone: true })
|
||||
.defaultNow()
|
||||
.notNull()
|
||||
.$onUpdateFn(() => new Date()),
|
||||
},
|
||||
(t) => [
|
||||
index('kobo_device_library_snapshots_user_id_idx').on(t.userId),
|
||||
unique('kobo_device_library_snapshots_device_id_unique').on(t.deviceId),
|
||||
foreignKey({
|
||||
columns: [t.deviceId, t.userId],
|
||||
foreignColumns: [koboDevices.id, koboDevices.userId],
|
||||
name: 'kobo_device_library_snapshots_device_owner_fk',
|
||||
}).onDelete('cascade'),
|
||||
],
|
||||
);
|
||||
|
||||
export const koboSnapshotBooks = pgTable(
|
||||
'kobo_device_snapshot_books',
|
||||
{
|
||||
snapshotId: integer('snapshot_id')
|
||||
.notNull()
|
||||
.references(() => koboLibrarySnapshots.id, { onDelete: 'cascade' }),
|
||||
bookId: integer('book_id')
|
||||
.notNull()
|
||||
.references(() => books.id, { onDelete: 'cascade' }),
|
||||
synced: boolean('synced').notNull().default(false),
|
||||
pendingDelete: boolean('pending_delete').notNull().default(false),
|
||||
isNew: boolean('is_new').notNull().default(true),
|
||||
removedByDevice: boolean('removed_by_device').notNull().default(false),
|
||||
needsLegacyNumericRemoval: boolean('needs_legacy_numeric_removal').notNull().default(false),
|
||||
fileHash: varchar('file_hash', { length: 64 }),
|
||||
deliveryHash: varchar('delivery_hash', { length: 64 }),
|
||||
metadataHash: varchar('metadata_hash', { length: 64 }),
|
||||
},
|
||||
(t) => [
|
||||
primaryKey({ columns: [t.snapshotId, t.bookId] }),
|
||||
index('kobo_device_snapshot_books_snapshot_synced_book_idx').on(t.snapshotId, t.synced, t.bookId),
|
||||
],
|
||||
);
|
||||
|
||||
export const koboBookEntitlements = pgTable(
|
||||
'kobo_book_entitlements',
|
||||
{
|
||||
|
||||
@@ -805,7 +805,7 @@ describe('BookController', () => {
|
||||
bookService.updateMetadataLocks.mockResolvedValue({ id: 7, lockedFields: ['title'] });
|
||||
bookService.refreshMetadata.mockResolvedValue({ id: 7 });
|
||||
bookService.getMetadataFromFile.mockResolvedValue({ title: 'File Title' });
|
||||
bookService.getKoboState.mockResolvedValue({ eligibleForKoboSync: false, syncCollections: [], readingState: null, snapshot: null });
|
||||
bookService.getKoboState.mockResolvedValue({ eligibleForKoboSync: false, syncCollections: [], readingState: null, snapshots: [] });
|
||||
bookService.getAudioProgress.mockResolvedValue({ positionSeconds: 15 });
|
||||
bookService.getDetail.mockResolvedValue({ id: 7, title: 'Detail' });
|
||||
|
||||
|
||||
@@ -233,7 +233,7 @@ describe('BookRepository', () => {
|
||||
},
|
||||
]);
|
||||
const koboReadingChain = makeSelectChain('limit', [{ createdAtKobo: null }]);
|
||||
const koboSnapshotChain = makeSelectChain('limit', [{ snapshotId: 8 }]);
|
||||
const koboSnapshotChain = makeSelectChain('orderBy', [{ deviceId: 3, deviceName: 'Libra', snapshotId: 8 }]);
|
||||
const koboCollectionsChain = makeSelectChain('where', [{ name: 'Sync Me' }]);
|
||||
const db = {
|
||||
select: vi
|
||||
@@ -282,7 +282,7 @@ describe('BookRepository', () => {
|
||||
},
|
||||
]);
|
||||
await expect(repo.findKoboReadingState(1, 10)).resolves.toEqual({ createdAtKobo: null });
|
||||
await expect(repo.findKoboSnapshotState(1, 10)).resolves.toEqual({ snapshotId: 8 });
|
||||
await expect(repo.findKoboSnapshotStates(1, 10)).resolves.toEqual([{ deviceId: 3, deviceName: 'Libra', snapshotId: 8 }]);
|
||||
await expect(repo.findKoboSyncCollectionNamesForBook(1, 10)).resolves.toEqual(['Sync Me']);
|
||||
});
|
||||
|
||||
|
||||
@@ -26,6 +26,7 @@ import {
|
||||
collectionBooks,
|
||||
collections,
|
||||
genres,
|
||||
koboDevices,
|
||||
koboLibrarySnapshots,
|
||||
koboReadingStates,
|
||||
koboSnapshotBooks,
|
||||
@@ -956,9 +957,11 @@ export class BookRepository {
|
||||
return row ?? null;
|
||||
}
|
||||
|
||||
async findKoboSnapshotState(userId: number, bookId: number) {
|
||||
const [row] = await this.db
|
||||
async findKoboSnapshotStates(userId: number, bookId: number) {
|
||||
return this.db
|
||||
.select({
|
||||
deviceId: koboDevices.id,
|
||||
deviceName: koboDevices.name,
|
||||
snapshotId: koboLibrarySnapshots.id,
|
||||
snapshotUpdatedAt: koboLibrarySnapshots.updatedAt,
|
||||
synced: koboSnapshotBooks.synced,
|
||||
@@ -969,10 +972,10 @@ export class BookRepository {
|
||||
metadataHash: koboSnapshotBooks.metadataHash,
|
||||
})
|
||||
.from(koboLibrarySnapshots)
|
||||
.innerJoin(koboDevices, and(eq(koboDevices.id, koboLibrarySnapshots.deviceId), eq(koboDevices.userId, userId)))
|
||||
.leftJoin(koboSnapshotBooks, and(eq(koboSnapshotBooks.snapshotId, koboLibrarySnapshots.id), eq(koboSnapshotBooks.bookId, bookId)))
|
||||
.where(eq(koboLibrarySnapshots.userId, userId))
|
||||
.limit(1);
|
||||
return row ?? null;
|
||||
.orderBy(asc(koboDevices.createdAt), asc(koboDevices.id));
|
||||
}
|
||||
|
||||
async findKoboSyncCollectionNamesForBook(userId: number, bookId: number): Promise<string[]> {
|
||||
|
||||
@@ -121,7 +121,7 @@ function makeService(overrides: { bookMetadataLockService?: unknown } = {}) {
|
||||
findRatingByBookAndUser: vi.fn().mockResolvedValue(null),
|
||||
findCollectionsByBookId: vi.fn(),
|
||||
findKoboReadingState: vi.fn(),
|
||||
findKoboSnapshotState: vi.fn(),
|
||||
findKoboSnapshotStates: vi.fn(),
|
||||
findKoboSyncCollectionNamesForBook: vi.fn(),
|
||||
findFileById: vi.fn(),
|
||||
findLibraryIdByBookId: vi.fn(),
|
||||
@@ -1922,7 +1922,7 @@ describe('BookService', () => {
|
||||
eligibleForKoboSync: false,
|
||||
syncCollections: [],
|
||||
readingState: null,
|
||||
snapshot: null,
|
||||
snapshots: [],
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1938,16 +1938,32 @@ describe('BookService', () => {
|
||||
priorityTimestamp: 'priority',
|
||||
updatedAt: new Date('2026-01-02T00:00:00.000Z'),
|
||||
});
|
||||
bookRepo.findKoboSnapshotState.mockResolvedValue({
|
||||
snapshotId: 99,
|
||||
snapshotUpdatedAt: new Date('2026-01-03T00:00:00.000Z'),
|
||||
synced: true,
|
||||
pendingDelete: false,
|
||||
isNew: false,
|
||||
removedByDevice: false,
|
||||
fileHash: 'fhash',
|
||||
metadataHash: 'mhash',
|
||||
});
|
||||
bookRepo.findKoboSnapshotStates.mockResolvedValue([
|
||||
{
|
||||
deviceId: 99,
|
||||
deviceName: 'Libra',
|
||||
snapshotId: 99,
|
||||
snapshotUpdatedAt: new Date('2026-01-03T00:00:00.000Z'),
|
||||
synced: true,
|
||||
pendingDelete: false,
|
||||
isNew: false,
|
||||
removedByDevice: false,
|
||||
fileHash: 'fhash',
|
||||
metadataHash: 'mhash',
|
||||
},
|
||||
{
|
||||
deviceId: 100,
|
||||
deviceName: 'Elipsa',
|
||||
snapshotId: 100,
|
||||
snapshotUpdatedAt: new Date('2026-01-04T00:00:00.000Z'),
|
||||
synced: false,
|
||||
pendingDelete: true,
|
||||
isNew: false,
|
||||
removedByDevice: false,
|
||||
fileHash: null,
|
||||
metadataHash: null,
|
||||
},
|
||||
]);
|
||||
bookRepo.findKoboSyncCollectionNamesForBook.mockResolvedValue(['Favorites']);
|
||||
|
||||
const result = await service.getKoboState(10, user);
|
||||
@@ -1955,7 +1971,10 @@ describe('BookService', () => {
|
||||
expect(result.eligibleForKoboSync).toBe(true);
|
||||
expect(result.readingState?.progressPercent).toBe(100);
|
||||
expect(result.readingState?.status).toBe('Reading');
|
||||
expect(result.snapshot?.snapshotId).toBe(99);
|
||||
expect(result.snapshots).toEqual([
|
||||
expect.objectContaining({ deviceId: 99, deviceName: 'Libra', snapshotId: 99, synced: true, inSnapshot: true }),
|
||||
expect.objectContaining({ deviceId: 100, deviceName: 'Elipsa', snapshotId: 100, synced: false, pendingDelete: true, fileHash: null }),
|
||||
]);
|
||||
});
|
||||
|
||||
it('ignores source-level Kobo progress in book state summaries', async () => {
|
||||
@@ -1970,7 +1989,7 @@ describe('BookService', () => {
|
||||
priorityTimestamp: 'priority',
|
||||
updatedAt: new Date('2026-01-02T00:00:00.000Z'),
|
||||
});
|
||||
bookRepo.findKoboSnapshotState.mockResolvedValue(null);
|
||||
bookRepo.findKoboSnapshotStates.mockResolvedValue([]);
|
||||
bookRepo.findKoboSyncCollectionNamesForBook.mockResolvedValue(['Favorites']);
|
||||
|
||||
const result = await service.getKoboState(10, user);
|
||||
|
||||
@@ -2429,13 +2429,13 @@ export class BookService {
|
||||
eligibleForKoboSync: false,
|
||||
syncCollections: [],
|
||||
readingState: null,
|
||||
snapshot: null,
|
||||
snapshots: [],
|
||||
};
|
||||
}
|
||||
|
||||
const [readingStateRow, snapshotRow, syncCollections] = await Promise.all([
|
||||
const [readingStateRow, snapshotRows, syncCollections] = await Promise.all([
|
||||
this.bookRepo.findKoboReadingState(user.id, id),
|
||||
this.bookRepo.findKoboSnapshotState(user.id, id),
|
||||
this.bookRepo.findKoboSnapshotStates(user.id, id),
|
||||
this.bookRepo.findKoboSyncCollectionNamesForBook(user.id, id),
|
||||
]);
|
||||
|
||||
@@ -2459,19 +2459,19 @@ export class BookService {
|
||||
updatedAt: readingStateRow.updatedAt.toISOString(),
|
||||
}
|
||||
: null,
|
||||
snapshot: snapshotRow
|
||||
? {
|
||||
snapshotId: snapshotRow.snapshotId,
|
||||
snapshotUpdatedAt: snapshotRow.snapshotUpdatedAt.toISOString(),
|
||||
inSnapshot: snapshotRow.synced !== null,
|
||||
synced: snapshotRow.synced ?? null,
|
||||
pendingDelete: snapshotRow.pendingDelete ?? null,
|
||||
isNew: snapshotRow.isNew ?? null,
|
||||
removedByDevice: snapshotRow.removedByDevice ?? null,
|
||||
fileHash: snapshotRow.fileHash ?? null,
|
||||
metadataHash: snapshotRow.metadataHash ?? null,
|
||||
}
|
||||
: null,
|
||||
snapshots: snapshotRows.map((snapshotRow) => ({
|
||||
deviceId: snapshotRow.deviceId,
|
||||
deviceName: snapshotRow.deviceName,
|
||||
snapshotId: snapshotRow.snapshotId,
|
||||
snapshotUpdatedAt: snapshotRow.snapshotUpdatedAt.toISOString(),
|
||||
inSnapshot: snapshotRow.synced !== null,
|
||||
synced: snapshotRow.synced ?? null,
|
||||
pendingDelete: snapshotRow.pendingDelete ?? null,
|
||||
isNew: snapshotRow.isNew ?? null,
|
||||
removedByDevice: snapshotRow.removedByDevice ?? null,
|
||||
fileHash: snapshotRow.fileHash ?? null,
|
||||
metadataHash: snapshotRow.metadataHash ?? null,
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -196,10 +196,10 @@ describe('KoboSyncController', () => {
|
||||
};
|
||||
const reply = makeReply();
|
||||
|
||||
await controller.librarySync({ deviceId: 9, 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, req as never, reply as never);
|
||||
|
||||
expect(settingsService.getSettings).not.toHaveBeenCalled();
|
||||
expect(syncService.getDelta).toHaveBeenCalledWith(21, 'token-9', 'http://kobo.local:3000');
|
||||
expect(syncService.getDelta).toHaveBeenCalledWith(21, 9, 'token-9', 'http://kobo.local:3000');
|
||||
expect(historyService.recordSuccess).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
userId: 21,
|
||||
@@ -225,7 +225,7 @@ describe('KoboSyncController', () => {
|
||||
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),
|
||||
controller.librarySync({ deviceId: 10, deviceToken: 'token-10' } as never, { id: 22 } as never, req as never, reply as never),
|
||||
).rejects.toThrow('snapshot failed');
|
||||
|
||||
expect(historyService.recordFailure).toHaveBeenCalledWith(expect.objectContaining({ userId: 22, deviceId: 10, event: 'library_sync' }), error);
|
||||
@@ -327,12 +327,12 @@ describe('KoboSyncController', () => {
|
||||
readingStateService.getRawState.mockResolvedValueOnce(null).mockResolvedValueOnce({ EntitlementId: '5' });
|
||||
|
||||
await controller.getBookMetadata('5', { id: 4 } as never, { deviceToken: 'token-5' } as never, req as never, reply as never);
|
||||
await controller.deleteFromLibrary('5', { id: 4 } as never, { deviceToken: 'token-5' } as never, req as never, reply as never);
|
||||
await controller.getReadingState('5', { id: 4 } as never, { deviceToken: 'token-5' } as never, req as never, reply as never);
|
||||
await controller.deleteFromLibrary('5', { id: 4 } as never, { deviceId: 5, deviceToken: 'token-5' } as never, req as never, reply as never);
|
||||
await controller.getReadingState('5', { id: 4 } as never, { deviceId: 5, deviceToken: 'token-5' } as never, req as never, reply as never);
|
||||
await controller.getReadingState('5', { id: 4 } as never, { deviceToken: 'token-5' } as never, req as never, reply as never);
|
||||
|
||||
expect(syncService.getBookMetadata).toHaveBeenCalledWith(4, 5, 'token-5', 'http://localhost:3000');
|
||||
expect(syncService.removeBookFromSync).toHaveBeenCalledWith(4, 5);
|
||||
expect(syncService.removeBookFromSync).toHaveBeenCalledWith(4, 5, 5);
|
||||
expect(reply.status).toHaveBeenCalledWith(HttpStatus.OK);
|
||||
expect(reply.send).toHaveBeenNthCalledWith(1, [{ Title: 'Dune' }]);
|
||||
expect(reply.send).toHaveBeenNthCalledWith(2);
|
||||
|
||||
@@ -4,7 +4,6 @@ import {
|
||||
Delete,
|
||||
Get,
|
||||
Header,
|
||||
Headers,
|
||||
HttpCode,
|
||||
HttpStatus,
|
||||
Logger,
|
||||
@@ -22,6 +21,7 @@ import type { FastifyReply, FastifyRequest } from 'fastify';
|
||||
import { Public } from '../../common/decorators/public.decorator';
|
||||
import { CurrentUser } from '../../common/decorators/current-user.decorator';
|
||||
import type { RequestUser } from '../../common/types/request-user';
|
||||
import { sanitizeLogValue } from '../../common/utils/log-sanitize.utils';
|
||||
import { KoboDevice } from './decorators/kobo-device.decorator';
|
||||
import type { KoboDeviceContext } from './guards/kobo-token.guard';
|
||||
import { KoboTokenGuard } from './guards/kobo-token.guard';
|
||||
@@ -117,17 +117,21 @@ export class KoboSyncController {
|
||||
async librarySync(
|
||||
@KoboDevice() device: KoboDeviceContext,
|
||||
@CurrentUser() user: RequestUser,
|
||||
@Headers('x-kobo-synctoken') incomingToken: string | undefined,
|
||||
@Req() req: FastifyRequest,
|
||||
@Res() reply: FastifyReply,
|
||||
) {
|
||||
const startedAt = Date.now();
|
||||
this.logger.log(`librarySync: userId=${user.id} syncToken=${incomingToken ?? 'none'}`);
|
||||
this.logger.debug(`[kobo.library_sync] [start] userId=${user.id} deviceId=${device.deviceId} - library sync started`);
|
||||
const baseUrl = buildBaseUrl(req);
|
||||
let result: { entitlements: unknown[]; hasMore: boolean; syncToken: string };
|
||||
try {
|
||||
result = await this.syncService.getDelta(user.id, device.deviceToken, baseUrl);
|
||||
result = await this.syncService.getDelta(user.id, device.deviceId, device.deviceToken, baseUrl);
|
||||
} catch (error: unknown) {
|
||||
const errorClass = error instanceof Error ? error.name : 'UnknownError';
|
||||
const errorMessage = sanitizeLogValue(error instanceof Error ? error.message : 'unknown error');
|
||||
this.logger.error(
|
||||
`[kobo.library_sync] [fail] userId=${user.id} deviceId=${device.deviceId} durationMs=${Date.now() - startedAt} errorClass=${errorClass} error="${errorMessage}" - library sync failed`,
|
||||
);
|
||||
await this.historyService.recordFailure(
|
||||
{
|
||||
userId: user.id,
|
||||
@@ -140,6 +144,9 @@ export class KoboSyncController {
|
||||
throw error;
|
||||
}
|
||||
const { entitlements, hasMore, syncToken } = result;
|
||||
this.logger.debug(
|
||||
`[kobo.library_sync] [end] userId=${user.id} deviceId=${device.deviceId} durationMs=${Date.now() - startedAt} entitlementCount=${entitlements.length} hasMore=${hasMore} - library sync completed`,
|
||||
);
|
||||
await this.historyService.recordSuccess({
|
||||
userId: user.id,
|
||||
deviceId: device.deviceId,
|
||||
@@ -204,7 +211,7 @@ export class KoboSyncController {
|
||||
) {
|
||||
const id = await this.bookIdentityService.resolveBookIdByEntitlementId(user.id, bookId);
|
||||
if (id === null) return this.proxyService.forward(req, reply, device.deviceToken);
|
||||
await this.syncService.removeBookFromSync(user.id, id);
|
||||
await this.syncService.removeBookFromSync(user.id, device.deviceId, id);
|
||||
reply.status(HttpStatus.OK).send();
|
||||
}
|
||||
|
||||
|
||||
@@ -9,6 +9,13 @@ function makeSelectChain(rows: unknown[]) {
|
||||
return { from, where, orderBy, limit };
|
||||
}
|
||||
|
||||
function collectNumericValues(value: unknown, seen = new Set<unknown>()): number[] {
|
||||
if (typeof value === 'number') return [value];
|
||||
if (!value || typeof value !== 'object' || seen.has(value)) return [];
|
||||
seen.add(value);
|
||||
return Object.values(value as Record<string, unknown>).flatMap((child) => collectNumericValues(child, seen));
|
||||
}
|
||||
|
||||
function makeDb() {
|
||||
return {
|
||||
query: {
|
||||
@@ -35,7 +42,7 @@ describe('KoboAnalyticsResolverService', () => {
|
||||
bookAccessService.assertBookAccessible.mockRejectedValue(new Error('forbidden'));
|
||||
const db = makeDb();
|
||||
|
||||
await expect(makeService(db).resolveBookFileId(1, 9)).resolves.toEqual({
|
||||
await expect(makeService(db).resolveBookFileId(1, 3, 9)).resolves.toEqual({
|
||||
kind: 'skipped',
|
||||
reason: 'book_not_accessible',
|
||||
});
|
||||
@@ -52,10 +59,11 @@ describe('KoboAnalyticsResolverService', () => {
|
||||
};
|
||||
db.select.mockReturnValueOnce(snapChain).mockReturnValueOnce(hashChain);
|
||||
|
||||
await expect(makeService(db).resolveBookFileId(1, 9)).resolves.toEqual({
|
||||
await expect(makeService(db).resolveBookFileId(101, 303, 909)).resolves.toEqual({
|
||||
kind: 'resolved',
|
||||
bookFileId: 42,
|
||||
});
|
||||
expect(collectNumericValues(snapChain.where.mock.calls[0]![0])).toEqual(expect.arrayContaining([101, 303, 909]));
|
||||
});
|
||||
|
||||
it('prefers the current primary when multiple files share the snapshot hash', async () => {
|
||||
@@ -69,7 +77,7 @@ describe('KoboAnalyticsResolverService', () => {
|
||||
};
|
||||
db.select.mockReturnValueOnce(snapChain).mockReturnValueOnce(hashChain);
|
||||
|
||||
await expect(makeService(db).resolveBookFileId(1, 9)).resolves.toEqual({
|
||||
await expect(makeService(db).resolveBookFileId(1, 3, 9)).resolves.toEqual({
|
||||
kind: 'resolved',
|
||||
bookFileId: 51,
|
||||
});
|
||||
@@ -86,7 +94,7 @@ describe('KoboAnalyticsResolverService', () => {
|
||||
};
|
||||
db.select.mockReturnValueOnce(snapChain).mockReturnValueOnce(hashChain);
|
||||
|
||||
await expect(makeService(db).resolveBookFileId(1, 9)).resolves.toEqual({
|
||||
await expect(makeService(db).resolveBookFileId(1, 3, 9)).resolves.toEqual({
|
||||
kind: 'resolved',
|
||||
bookFileId: 44,
|
||||
});
|
||||
@@ -99,7 +107,7 @@ describe('KoboAnalyticsResolverService', () => {
|
||||
db.select.mockReturnValueOnce(snapChain);
|
||||
db.query.bookFiles.findFirst.mockResolvedValue({ id: 50 });
|
||||
|
||||
await expect(makeService(db).resolveBookFileId(1, 9)).resolves.toEqual({
|
||||
await expect(makeService(db).resolveBookFileId(1, 3, 9)).resolves.toEqual({
|
||||
kind: 'resolved',
|
||||
bookFileId: 50,
|
||||
});
|
||||
@@ -117,7 +125,7 @@ describe('KoboAnalyticsResolverService', () => {
|
||||
db.select.mockReturnValueOnce(snapChain).mockReturnValueOnce(hashChain);
|
||||
db.query.bookFiles.findFirst.mockResolvedValue({ id: 50 });
|
||||
|
||||
await expect(makeService(db).resolveBookFileId(1, 9)).resolves.toEqual({
|
||||
await expect(makeService(db).resolveBookFileId(1, 3, 9)).resolves.toEqual({
|
||||
kind: 'resolved',
|
||||
bookFileId: 50,
|
||||
});
|
||||
@@ -129,7 +137,7 @@ describe('KoboAnalyticsResolverService', () => {
|
||||
const snapChain = makeSelectChain([]);
|
||||
db.select.mockReturnValueOnce(snapChain);
|
||||
|
||||
await expect(makeService(db).resolveBookFileId(1, 9)).resolves.toEqual({
|
||||
await expect(makeService(db).resolveBookFileId(1, 3, 9)).resolves.toEqual({
|
||||
kind: 'skipped',
|
||||
reason: 'no_epub_file',
|
||||
});
|
||||
@@ -139,7 +147,7 @@ describe('KoboAnalyticsResolverService', () => {
|
||||
const db = makeDb();
|
||||
db.query.books.findFirst.mockResolvedValue(null);
|
||||
|
||||
await expect(makeService(db).resolveBookFileId(1, 9)).resolves.toEqual({
|
||||
await expect(makeService(db).resolveBookFileId(1, 3, 9)).resolves.toEqual({
|
||||
kind: 'skipped',
|
||||
reason: 'book_not_found',
|
||||
});
|
||||
@@ -152,7 +160,7 @@ describe('KoboAnalyticsResolverService', () => {
|
||||
db.select.mockReturnValueOnce(snapChain);
|
||||
db.query.bookFiles.findFirst.mockResolvedValue(null);
|
||||
|
||||
await expect(makeService(db).resolveBookFileId(1, 9)).resolves.toEqual({
|
||||
await expect(makeService(db).resolveBookFileId(1, 3, 9)).resolves.toEqual({
|
||||
kind: 'skipped',
|
||||
reason: 'no_epub_file',
|
||||
});
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { Inject, Injectable } from '@nestjs/common';
|
||||
import { and, desc, eq, inArray } from 'drizzle-orm';
|
||||
import { and, eq, inArray } from 'drizzle-orm';
|
||||
import type { NodePgDatabase } from 'drizzle-orm/node-postgres';
|
||||
|
||||
import { DB } from '../../../db/db.module';
|
||||
@@ -19,7 +19,7 @@ export class KoboAnalyticsResolverService {
|
||||
private readonly bookAccessService: KoboBookAccessService,
|
||||
) {}
|
||||
|
||||
async resolveBookFileId(userId: number, bookId: number): Promise<KoboAnalyticsResolveResult> {
|
||||
async resolveBookFileId(userId: number, deviceId: number, bookId: number): Promise<KoboAnalyticsResolveResult> {
|
||||
try {
|
||||
await this.bookAccessService.assertBookAccessible(userId, bookId);
|
||||
} catch {
|
||||
@@ -36,8 +36,13 @@ export class KoboAnalyticsResolverService {
|
||||
.select({ fileHash: schema.koboSnapshotBooks.fileHash })
|
||||
.from(schema.koboSnapshotBooks)
|
||||
.innerJoin(schema.koboLibrarySnapshots, eq(schema.koboLibrarySnapshots.id, schema.koboSnapshotBooks.snapshotId))
|
||||
.where(and(eq(schema.koboLibrarySnapshots.userId, userId), eq(schema.koboSnapshotBooks.bookId, bookId)))
|
||||
.orderBy(desc(schema.koboLibrarySnapshots.id))
|
||||
.where(
|
||||
and(
|
||||
eq(schema.koboLibrarySnapshots.userId, userId),
|
||||
eq(schema.koboLibrarySnapshots.deviceId, deviceId),
|
||||
eq(schema.koboSnapshotBooks.bookId, bookId),
|
||||
),
|
||||
)
|
||||
.limit(1);
|
||||
|
||||
const snapHash = snap[0]?.fileHash ?? null;
|
||||
|
||||
@@ -53,6 +53,7 @@ describe('KoboAnalyticsService', () => {
|
||||
await makeService().ingest({ Events: events }, user, device);
|
||||
|
||||
expect(resolver.resolveBookFileId).toHaveBeenCalledTimes(3);
|
||||
expect(resolver.resolveBookFileId).toHaveBeenCalledWith(7, 2, 1);
|
||||
expect(readingSessionService.save).toHaveBeenCalledTimes(3);
|
||||
expect(readingSessionService.save).toHaveBeenNthCalledWith(
|
||||
1,
|
||||
@@ -396,7 +397,7 @@ describe('KoboAnalyticsService', () => {
|
||||
);
|
||||
|
||||
expect(bookIdentityService.resolveBookIdByEntitlementId).toHaveBeenCalledWith(7, entitlementId);
|
||||
expect(resolver.resolveBookFileId).toHaveBeenCalledWith(7, 9);
|
||||
expect(resolver.resolveBookFileId).toHaveBeenCalledWith(7, 2, 9);
|
||||
expect(readingSessionService.save).toHaveBeenCalledWith(100, expect.objectContaining({ sessionId: 'uuid-volume', endProgress: 8 }), user, 'kobo');
|
||||
});
|
||||
|
||||
|
||||
@@ -82,7 +82,7 @@ export class KoboAnalyticsService {
|
||||
for (const ev of events) {
|
||||
try {
|
||||
if (ev.EventType === 'LeaveContent') {
|
||||
await this.handleLeaveContent(ev, user, leaveContentContexts.get(ev) ?? { progressDelta: null, startedAtMs: null });
|
||||
await this.handleLeaveContent(ev, user, device.deviceId, leaveContentContexts.get(ev) ?? { progressDelta: null, startedAtMs: null });
|
||||
} else if (ev.EventType === 'RateBook') {
|
||||
await this.handleRateBook(ev, user);
|
||||
}
|
||||
@@ -134,7 +134,7 @@ export class KoboAnalyticsService {
|
||||
await this.bookService.bulkSetRating([bookId], rating, user);
|
||||
}
|
||||
|
||||
private async handleLeaveContent(ev: KoboAnalyticsEvent, user: RequestUser, context: LeaveContentContext): Promise<void> {
|
||||
private async handleLeaveContent(ev: KoboAnalyticsEvent, user: RequestUser, deviceId: number, context: LeaveContentContext): Promise<void> {
|
||||
const volumeid = this.extractVolumeId(ev);
|
||||
const durationSeconds = parseKoboDurationSeconds(ev.Metrics);
|
||||
if (volumeid === null || durationSeconds === null) {
|
||||
@@ -152,7 +152,7 @@ export class KoboAnalyticsService {
|
||||
return;
|
||||
}
|
||||
|
||||
const resolved = await this.resolver.resolveBookFileId(user.id, bookId);
|
||||
const resolved = await this.resolver.resolveBookFileId(user.id, deviceId, bookId);
|
||||
if (resolved.kind !== 'resolved') {
|
||||
this.logger.log(`[kobo.analytics.session] [skip] bookId=${bookId} userId=${user.id} reason=${resolved.reason}`);
|
||||
return;
|
||||
|
||||
@@ -44,17 +44,10 @@ function makeDb() {
|
||||
}
|
||||
|
||||
describe('KoboDeviceService', () => {
|
||||
const syncService = { invalidateSnapshot: vi.fn() };
|
||||
|
||||
function makeService(db: ReturnType<typeof makeDb>) {
|
||||
return new KoboDeviceService(db as never, syncService as never);
|
||||
return new KoboDeviceService(db as never);
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
syncService.invalidateSnapshot.mockResolvedValue(undefined);
|
||||
});
|
||||
|
||||
it('lists devices for user ordered by creation time', async () => {
|
||||
const db = makeDb();
|
||||
const rows = [{ id: 1, name: 'Aura' }];
|
||||
@@ -66,7 +59,6 @@ describe('KoboDeviceService', () => {
|
||||
|
||||
it('creates a device with hyphen-free token and returns persisted fields', async () => {
|
||||
const db = makeDb();
|
||||
db.select.mockReturnValueOnce(makeSelectChain([], []));
|
||||
db.insertReturning.mockResolvedValueOnce([
|
||||
{
|
||||
id: 22,
|
||||
@@ -92,26 +84,7 @@ describe('KoboDeviceService', () => {
|
||||
token: expect.stringMatching(/^[0-9a-f]+$/i),
|
||||
}),
|
||||
);
|
||||
expect(syncService.invalidateSnapshot).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('invalidates the user snapshot when adding another device', async () => {
|
||||
const db = makeDb();
|
||||
db.select.mockReturnValueOnce(makeSelectChain([], [{ id: 3 }]));
|
||||
db.insertReturning.mockResolvedValueOnce([
|
||||
{
|
||||
id: 22,
|
||||
name: 'Libra',
|
||||
token: 'server-generated-token',
|
||||
lastSeenAt: null,
|
||||
createdAt: new Date('2026-01-01T00:00:00.000Z'),
|
||||
},
|
||||
]);
|
||||
const service = makeService(db);
|
||||
|
||||
await service.createDevice(8, 'Libra');
|
||||
|
||||
expect(syncService.invalidateSnapshot).toHaveBeenCalledWith(8);
|
||||
expect(db.select).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('renames existing devices and throws when device is missing', async () => {
|
||||
@@ -135,14 +108,11 @@ describe('KoboDeviceService', () => {
|
||||
const service = makeService(db);
|
||||
|
||||
await expect(service.revokeDevice(8, 111)).rejects.toThrow(NotFoundException);
|
||||
expect(syncService.invalidateSnapshot).not.toHaveBeenCalled();
|
||||
expect(db.delete).not.toHaveBeenCalled();
|
||||
expect(db.deleteWhere).not.toHaveBeenCalled();
|
||||
|
||||
await expect(service.revokeDevice(8, 7)).resolves.toBeUndefined();
|
||||
expect(db.delete).toHaveBeenCalledTimes(1);
|
||||
expect(db.deleteWhere).toHaveBeenCalledTimes(1);
|
||||
expect(syncService.invalidateSnapshot).toHaveBeenCalledTimes(1);
|
||||
expect(syncService.invalidateSnapshot).toHaveBeenCalledWith(8);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -5,16 +5,12 @@ import { NodePgDatabase } from 'drizzle-orm/node-postgres';
|
||||
|
||||
import { DB } from '../../../db/db.module';
|
||||
import * as schema from '../../../db/schema';
|
||||
import { KoboSyncService } from './kobo-sync.service';
|
||||
|
||||
type Db = NodePgDatabase<typeof schema>;
|
||||
|
||||
@Injectable()
|
||||
export class KoboDeviceService {
|
||||
constructor(
|
||||
@Inject(DB) private readonly db: Db,
|
||||
private readonly syncService: KoboSyncService,
|
||||
) {}
|
||||
constructor(@Inject(DB) private readonly db: Db) {}
|
||||
|
||||
async listDevices(userId: number) {
|
||||
const rows = await this.db
|
||||
@@ -31,19 +27,9 @@ export class KoboDeviceService {
|
||||
}
|
||||
|
||||
async createDevice(userId: number, name: string) {
|
||||
const [existingDevice] = await this.db
|
||||
.select({ id: schema.koboDevices.id })
|
||||
.from(schema.koboDevices)
|
||||
.where(eq(schema.koboDevices.userId, userId))
|
||||
.limit(1);
|
||||
|
||||
const token = randomUUID().replace(/-/g, '');
|
||||
const [device] = await this.db.insert(schema.koboDevices).values({ userId, name, token }).returning();
|
||||
|
||||
if (existingDevice) {
|
||||
await this.syncService.invalidateSnapshot(userId);
|
||||
}
|
||||
|
||||
return {
|
||||
id: device.id,
|
||||
name: device.name,
|
||||
@@ -85,6 +71,5 @@ export class KoboDeviceService {
|
||||
if (!device) throw new NotFoundException('Device not found');
|
||||
|
||||
await this.db.delete(schema.koboDevices).where(and(eq(schema.koboDevices.id, deviceId), eq(schema.koboDevices.userId, userId)));
|
||||
await this.syncService.invalidateSnapshot(userId);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -72,12 +72,17 @@ function makeDb(state?: Partial<QueueState>) {
|
||||
execute: [...(state?.execute ?? [])],
|
||||
};
|
||||
const chains: ReturnType<typeof makeChain>[] = [];
|
||||
const transactionChains: ReturnType<typeof makeChain>[] = [];
|
||||
const transactionExecutes: ReturnType<typeof vi.fn>[] = [];
|
||||
|
||||
return {
|
||||
__chains: chains,
|
||||
__transactionChains: transactionChains,
|
||||
__transactionExecutes: transactionExecutes,
|
||||
query: {
|
||||
users: { findFirst: vi.fn().mockResolvedValue({ settings: {} }) },
|
||||
koboLibrarySnapshots: { findFirst: vi.fn() },
|
||||
koboLegacyLibrarySnapshots: { findFirst: vi.fn().mockResolvedValue(undefined) },
|
||||
koboSnapshotBooks: { findFirst: vi.fn() },
|
||||
koboSyncSettings: { findFirst: vi.fn() },
|
||||
collections: { findMany: vi.fn() },
|
||||
@@ -92,11 +97,20 @@ function makeDb(state?: Partial<QueueState>) {
|
||||
update: vi.fn(() => makeChain(queue.update.shift() ?? [])),
|
||||
delete: vi.fn(() => makeChain(queue.delete.shift() ?? [])),
|
||||
execute: vi.fn(() => Promise.resolve({ rows: queue.execute.shift() ?? [] })),
|
||||
transaction: vi.fn(async (cb: (tx: { execute: (statement: unknown) => Promise<unknown> }) => Promise<void>) => {
|
||||
await cb({
|
||||
execute: vi.fn().mockResolvedValue(undefined),
|
||||
});
|
||||
}),
|
||||
transaction: vi.fn(
|
||||
async (cb: (tx: { execute: (statement: unknown) => Promise<unknown>; insert: ReturnType<typeof vi.fn> }) => Promise<unknown>) => {
|
||||
const execute = vi.fn().mockResolvedValue(undefined);
|
||||
transactionExecutes.push(execute);
|
||||
return cb({
|
||||
execute,
|
||||
insert: vi.fn(() => {
|
||||
const chain = makeChain(queue.insert.shift() ?? []);
|
||||
transactionChains.push(chain);
|
||||
return chain;
|
||||
}),
|
||||
});
|
||||
},
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -185,31 +199,55 @@ describe('KoboSyncService', () => {
|
||||
);
|
||||
});
|
||||
|
||||
it('getDelta creates new snapshot when missing and reconciles existing snapshot otherwise', async () => {
|
||||
it('creates and pages independent snapshots for devices sharing a user', async () => {
|
||||
const db = makeDb();
|
||||
const service = makeService(db);
|
||||
const eligible = [{ bookId: 1, fileHash: 'h1', deliveryHash: 'd1', metadataHash: 'm1' }];
|
||||
const eligible = [{ bookId: 1, fileHash: 'h1', deliveryHash: 'd1', metadataHash: 'm1', needsLegacyNumericRemoval: false }];
|
||||
vi.spyOn(service as any, 'fetchEligibleSnapshotRows').mockResolvedValue(eligible);
|
||||
const createSpy = vi.spyOn(service as any, 'createSnapshot').mockResolvedValue(undefined);
|
||||
const reconcileSpy = vi.spyOn(service as any, 'reconcileSnapshot').mockResolvedValue(undefined);
|
||||
vi.spyOn(service as any, 'findDeviceSnapshot').mockResolvedValue(undefined);
|
||||
vi.spyOn(service as any, 'hasDeviceSnapshot')
|
||||
.mockResolvedValueOnce(false)
|
||||
.mockResolvedValueOnce(true);
|
||||
vi.spyOn(service as any, 'getLegacyNumericRemovalBookIds').mockResolvedValue(new Set());
|
||||
vi.spyOn(service as any, 'retireLegacySnapshotIfComplete').mockResolvedValue(undefined);
|
||||
const createSpy = vi.spyOn(service as any, 'createSnapshot').mockImplementation((_userId: number, deviceId: number) => ({
|
||||
snapshot: { id: deviceId === 101 ? 11 : 22, userId: 5, deviceId },
|
||||
created: true,
|
||||
}));
|
||||
const pageSpy = vi.spyOn(service as any, 'getPageFromSnapshot').mockResolvedValue({
|
||||
entitlements: [{ ChangedTag: {} }],
|
||||
hasMore: false,
|
||||
syncToken: 'PX.token',
|
||||
});
|
||||
|
||||
db.query.koboLibrarySnapshots.findFirst.mockResolvedValueOnce(null).mockResolvedValueOnce({ id: 9, userId: 5 });
|
||||
|
||||
await expect(service.getDelta(5, 'device-token', 'https://reader.example.com')).resolves.toEqual({
|
||||
await expect(service.getDelta(5, 101, 'device-a-token', 'https://reader.example.com')).resolves.toEqual({
|
||||
entitlements: [{ ChangedTag: {} }],
|
||||
hasMore: false,
|
||||
syncToken: 'PX.token',
|
||||
});
|
||||
expect(createSpy).toHaveBeenCalledWith(5, eligible);
|
||||
await service.getDelta(5, 202, 'device-b-token', 'https://reader.example.com');
|
||||
|
||||
expect(createSpy).toHaveBeenNthCalledWith(1, 5, 101, eligible, new Set());
|
||||
expect(createSpy).toHaveBeenNthCalledWith(2, 5, 202, eligible, new Set());
|
||||
expect((service as any).fetchEligibleSnapshotRows).toHaveBeenNthCalledWith(1, 5, false, expect.any(Map));
|
||||
expect((service as any).fetchEligibleSnapshotRows).toHaveBeenNthCalledWith(2, 5, true, expect.any(Map));
|
||||
expect(pageSpy).toHaveBeenNthCalledWith(1, 5, 11, 'device-a-token', 'https://reader.example.com', new Set([1]), expect.any(Map));
|
||||
expect(pageSpy).toHaveBeenNthCalledWith(2, 5, 22, 'device-b-token', 'https://reader.example.com', new Set([1]), expect.any(Map));
|
||||
});
|
||||
|
||||
it('reconciles only the caller device snapshot when it already exists', async () => {
|
||||
const db = makeDb();
|
||||
const service = makeService(db);
|
||||
const eligible = [{ bookId: 1, fileHash: 'h1', deliveryHash: 'd1', metadataHash: 'm1', needsLegacyNumericRemoval: false }];
|
||||
vi.spyOn(service as any, 'findDeviceSnapshot').mockResolvedValue({ id: 9, userId: 5, deviceId: 101 });
|
||||
vi.spyOn(service as any, 'fetchEligibleSnapshotRows').mockResolvedValue(eligible);
|
||||
const reconcileSpy = vi.spyOn(service as any, 'reconcileSnapshot').mockResolvedValue(undefined);
|
||||
const pageSpy = vi.spyOn(service as any, 'getPageFromSnapshot').mockResolvedValue({ entitlements: [], hasMore: false, syncToken: 'PX.token' });
|
||||
|
||||
await service.getDelta(5, 101, 'device-a-token', 'https://reader.example.com');
|
||||
|
||||
await service.getDelta(5, 'device-token', 'https://reader.example.com');
|
||||
expect(reconcileSpy).toHaveBeenCalledWith(9, eligible);
|
||||
expect(pageSpy).toHaveBeenCalledTimes(2);
|
||||
expect(pageSpy).toHaveBeenCalledWith(5, 9, 'device-a-token', 'https://reader.example.com', new Set([1]), expect.any(Map));
|
||||
});
|
||||
|
||||
it('getBookMetadata returns empty array when book is not eligible', async () => {
|
||||
@@ -244,67 +282,172 @@ describe('KoboSyncService', () => {
|
||||
const db = makeDb();
|
||||
const service = makeService(db);
|
||||
|
||||
db.query.koboLibrarySnapshots.findFirst
|
||||
vi.spyOn(service as any, 'findDeviceSnapshot')
|
||||
.mockResolvedValueOnce(null)
|
||||
.mockResolvedValueOnce({ id: 4, userId: 1 })
|
||||
.mockResolvedValueOnce({ id: 4, userId: 1 })
|
||||
.mockResolvedValueOnce({ id: 4, userId: 1 });
|
||||
.mockResolvedValueOnce({ id: 4, userId: 1, deviceId: 91 })
|
||||
.mockResolvedValueOnce({ id: 4, userId: 1, deviceId: 91 })
|
||||
.mockResolvedValueOnce({ id: 4, userId: 1, deviceId: 91 });
|
||||
db.query.koboSnapshotBooks.findFirst
|
||||
.mockResolvedValueOnce(undefined)
|
||||
.mockResolvedValueOnce({ bookId: 2, pendingDelete: true })
|
||||
.mockResolvedValueOnce({ bookId: 3, pendingDelete: false });
|
||||
|
||||
await expect(service.removeBookFromSync(1, 10)).resolves.toBeUndefined();
|
||||
await expect(service.removeBookFromSync(1, 2)).resolves.toBeUndefined();
|
||||
await expect(service.removeBookFromSync(1, 3)).resolves.toBeUndefined();
|
||||
await expect(service.removeBookFromSync(1, 4)).resolves.toBeUndefined();
|
||||
await expect(service.removeBookFromSync(1, 91, 10)).resolves.toBeUndefined();
|
||||
await expect(service.removeBookFromSync(1, 91, 2)).resolves.toBeUndefined();
|
||||
await expect(service.removeBookFromSync(1, 91, 3)).resolves.toBeUndefined();
|
||||
await expect(service.removeBookFromSync(1, 91, 4)).resolves.toBeUndefined();
|
||||
|
||||
expect(db.delete).toHaveBeenCalled();
|
||||
expect(db.update).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('invalidateSnapshot marks snapshot rows unsynced only when snapshot exists', async () => {
|
||||
const db = makeDb();
|
||||
db.query.koboLibrarySnapshots.findFirst.mockResolvedValueOnce(null).mockResolvedValueOnce({ id: 21, userId: 9 });
|
||||
const service = makeService(db);
|
||||
|
||||
await service.invalidateSnapshot(9);
|
||||
await service.invalidateSnapshot(9);
|
||||
|
||||
expect(db.update).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('createSnapshot inserts snapshot row and snapshot-books seed rows', async () => {
|
||||
it('createSnapshot seeds a device snapshot and uses a set-based query for removal tombstones', async () => {
|
||||
const db = makeDb({ insert: [[{ id: 55 }], []] });
|
||||
const service = makeService(db);
|
||||
|
||||
await (service as any).createSnapshot(7, [
|
||||
{ bookId: 1, fileHash: 'h1', deliveryHash: 'd1', metadataHash: 'm1' },
|
||||
{ bookId: 2, fileHash: null, deliveryHash: 'd2', metadataHash: 'm2' },
|
||||
]);
|
||||
const result = await (service as any).createSnapshot(
|
||||
7,
|
||||
99,
|
||||
[
|
||||
{ bookId: 1, fileHash: 'h1', deliveryHash: 'd1', metadataHash: 'm1', needsLegacyNumericRemoval: true },
|
||||
{ bookId: 2, fileHash: null, deliveryHash: 'd2', metadataHash: 'm2', needsLegacyNumericRemoval: false },
|
||||
],
|
||||
new Set([2]),
|
||||
);
|
||||
|
||||
expect(db.insert).toHaveBeenCalledTimes(2);
|
||||
expect(result).toEqual({ snapshot: { id: 55 }, created: true });
|
||||
expect(db.__transactionChains).toHaveLength(3);
|
||||
expect(db.__transactionChains[0].values).toHaveBeenCalledWith({ userId: 7, deviceId: 99 });
|
||||
expect(db.__transactionChains[1].values).toHaveBeenCalledWith([
|
||||
expect.objectContaining({ snapshotId: 55, bookId: 1, pendingDelete: false, isNew: true, needsLegacyNumericRemoval: true }),
|
||||
expect.objectContaining({ snapshotId: 55, bookId: 2, pendingDelete: false, isNew: true, needsLegacyNumericRemoval: true }),
|
||||
]);
|
||||
expect(db.__transactionChains[2].values).toHaveBeenCalledWith([
|
||||
expect.objectContaining({ snapshotId: 55, bookId: 2, pendingDelete: true, isNew: false, needsLegacyNumericRemoval: true }),
|
||||
]);
|
||||
expect(db.__transactionChains[2].onConflictDoUpdate).toHaveBeenCalledWith({
|
||||
target: [schema.koboSnapshotBooks.snapshotId, schema.koboSnapshotBooks.bookId],
|
||||
set: { needsLegacyNumericRemoval: true },
|
||||
});
|
||||
expect(db.__transactionExecutes[0]).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('getPageFromSnapshot returns empty result when no snapshot exists', async () => {
|
||||
const db = makeDb();
|
||||
db.query.koboLibrarySnapshots.findFirst.mockResolvedValue(null);
|
||||
it('creates a legacy numeric tombstone for an ineligible book even when its identity was already completed', async () => {
|
||||
const db = makeDb({ insert: [[{ id: 55 }], []] });
|
||||
const service = makeService(db);
|
||||
|
||||
await expect((service as any).getPageFromSnapshot(7, 'tok', 'https://base', new Set(), new Map())).resolves.toEqual({
|
||||
entitlements: [],
|
||||
hasMore: false,
|
||||
syncToken: expect.stringMatching(/^PX\./),
|
||||
await (service as any).createSnapshot(7, 99, [], new Set([42]));
|
||||
|
||||
expect(db.__transactionChains).toHaveLength(2);
|
||||
expect(db.__transactionChains[1].values).toHaveBeenCalledWith([
|
||||
expect.objectContaining({
|
||||
snapshotId: 55,
|
||||
bookId: 42,
|
||||
synced: false,
|
||||
pendingDelete: true,
|
||||
isNew: false,
|
||||
needsLegacyNumericRemoval: true,
|
||||
}),
|
||||
]);
|
||||
expect(db.__transactionChains[1].onConflictDoUpdate).toHaveBeenCalledWith({
|
||||
target: [schema.koboSnapshotBooks.snapshotId, schema.koboSnapshotBooks.bookId],
|
||||
set: { needsLegacyNumericRemoval: true },
|
||||
});
|
||||
});
|
||||
|
||||
it('batches initial snapshot seed rows for large libraries', async () => {
|
||||
const db = makeDb({ insert: [[{ id: 55 }], [], []] });
|
||||
const service = makeService(db);
|
||||
const eligible = Array.from({ length: 5001 }, (_, index) => ({
|
||||
bookId: index + 1,
|
||||
fileHash: `file-${index}`,
|
||||
deliveryHash: `delivery-${index}`,
|
||||
metadataHash: `metadata-${index}`,
|
||||
needsLegacyNumericRemoval: false,
|
||||
}));
|
||||
|
||||
await (service as any).createSnapshot(7, 99, eligible, new Set());
|
||||
|
||||
expect(db.__transactionChains).toHaveLength(3);
|
||||
expect(db.__transactionChains[1].values).toHaveBeenCalledWith(expect.arrayContaining([expect.objectContaining({ snapshotId: 55, bookId: 1 })]));
|
||||
expect(db.__transactionChains[2].values).toHaveBeenCalledWith([expect.objectContaining({ snapshotId: 55, bookId: 5001 })]);
|
||||
});
|
||||
|
||||
it('reuses a concurrently-created device snapshot after a unique conflict', async () => {
|
||||
const db = makeDb({ insert: [[]] });
|
||||
const service = makeService(db);
|
||||
vi.spyOn(service as any, 'findDeviceSnapshot').mockResolvedValue({ id: 55, userId: 7, deviceId: 99 });
|
||||
|
||||
await expect((service as any).createSnapshot(7, 99, [], new Set())).resolves.toEqual({
|
||||
snapshot: { id: 55, userId: 7, deviceId: 99 },
|
||||
created: false,
|
||||
});
|
||||
});
|
||||
|
||||
it('uses the preserved legacy snapshot to mark every old numeric id for each legacy device', async () => {
|
||||
const db = makeDb({
|
||||
select: [[{ id: 88, legacyDeviceCutoffAt: new Date('2026-01-01T00:00:00.000Z') }], [{ bookId: 1 }, { bookId: 2 }]],
|
||||
});
|
||||
const service = makeService(db);
|
||||
bookIdentityService.ensureForBooks.mockResolvedValue(
|
||||
new Map([
|
||||
[1, makeIdentity(1, true)],
|
||||
[2, makeIdentity(2, false)],
|
||||
]),
|
||||
);
|
||||
|
||||
await expect((service as any).getLegacyNumericRemovalBookIds(7, 99)).resolves.toEqual(new Set([1, 2]));
|
||||
expect(bookIdentityService.ensureForBooks).toHaveBeenCalledWith(7, [1, 2], true);
|
||||
});
|
||||
|
||||
it('batches legacy identity initialization for large pre-upgrade snapshots', async () => {
|
||||
const legacyBooks = Array.from({ length: 5001 }, (_, index) => ({ bookId: index + 1 }));
|
||||
const db = makeDb({
|
||||
select: [[{ id: 88, legacyDeviceCutoffAt: new Date('2026-01-01T00:00:00.000Z') }], legacyBooks],
|
||||
});
|
||||
const service = makeService(db);
|
||||
|
||||
const ids = await (service as any).getLegacyNumericRemovalBookIds(7, 99);
|
||||
|
||||
expect(ids).toHaveLength(5001);
|
||||
expect(bookIdentityService.ensureForBooks).toHaveBeenCalledTimes(2);
|
||||
expect(bookIdentityService.ensureForBooks).toHaveBeenNthCalledWith(
|
||||
1,
|
||||
7,
|
||||
Array.from({ length: 5000 }, (_, index) => index + 1),
|
||||
true,
|
||||
);
|
||||
expect(bookIdentityService.ensureForBooks).toHaveBeenNthCalledWith(2, 7, [5001], true);
|
||||
});
|
||||
|
||||
it('waits for every device queue before clearing a legacy numeric-removal identity', async () => {
|
||||
const db = makeDb({ select: [[{ bookId: 3 }], []] });
|
||||
const service = makeService(db);
|
||||
vi.spyOn(service as any, 'hasUninitializedLegacyDevices').mockResolvedValue(false);
|
||||
|
||||
await (service as any).completeLegacyNumericRemovals(7, 22, [3]);
|
||||
expect(bookIdentityService.markLegacyNumericRemovalComplete).not.toHaveBeenCalled();
|
||||
|
||||
await (service as any).completeLegacyNumericRemovals(7, 23, [3]);
|
||||
expect(bookIdentityService.markLegacyNumericRemovalComplete).toHaveBeenCalledWith(7, [3]);
|
||||
});
|
||||
|
||||
it('retains identity-level legacy removal state while a pre-upgrade device has not initialized', async () => {
|
||||
const db = makeDb({ select: [[]] });
|
||||
const service = makeService(db);
|
||||
vi.spyOn(service as any, 'hasUninitializedLegacyDevices').mockResolvedValue(true);
|
||||
|
||||
await (service as any).completeLegacyNumericRemovals(7, 22, [3]);
|
||||
|
||||
expect(bookIdentityService.markLegacyNumericRemovalComplete).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('getPageFromSnapshot returns tags on final page when no pending rows', async () => {
|
||||
const db = makeDb({ select: [[]] });
|
||||
db.query.koboLibrarySnapshots.findFirst.mockResolvedValue({ id: 1, userId: 7 });
|
||||
const service = makeService(db);
|
||||
vi.spyOn(service as any, 'buildTagItems').mockResolvedValue([{ ChangedTag: {} }]);
|
||||
|
||||
const result = await (service as any).getPageFromSnapshot(7, 'tok', 'https://base', new Set([1]), new Map());
|
||||
const result = await (service as any).getPageFromSnapshot(7, 1, 'tok', 'https://base', new Set([1]), new Map());
|
||||
|
||||
expect(result).toEqual({
|
||||
entitlements: [{ ChangedTag: {} }],
|
||||
@@ -322,7 +465,6 @@ describe('KoboSyncService', () => {
|
||||
],
|
||||
],
|
||||
});
|
||||
db.query.koboLibrarySnapshots.findFirst.mockResolvedValue({ id: 22, userId: 7 });
|
||||
const service = makeService(db);
|
||||
vi.spyOn(service as any, 'fetchEligibleBooksByIds').mockResolvedValue(
|
||||
new Map([
|
||||
@@ -332,7 +474,7 @@ describe('KoboSyncService', () => {
|
||||
);
|
||||
readingStateService.getRawState.mockResolvedValue(null);
|
||||
|
||||
const result = await (service as any).getPageFromSnapshot(7, 'tok', 'https://base', new Set([1, 2, 3]), new Map());
|
||||
const result = await (service as any).getPageFromSnapshot(7, 22, 'tok', 'https://base', new Set([1, 2, 3]), new Map());
|
||||
|
||||
expect(result.hasMore).toBe(false);
|
||||
expect(result.entitlements).toHaveLength(3);
|
||||
@@ -347,12 +489,11 @@ describe('KoboSyncService', () => {
|
||||
const db = makeDb({
|
||||
select: [[{ bookId: 3, pendingDelete: false, isNew: false }]],
|
||||
});
|
||||
db.query.koboLibrarySnapshots.findFirst.mockResolvedValue({ id: 22, userId: 7 });
|
||||
const service = makeService(db);
|
||||
vi.spyOn(service as any, 'fetchEligibleBooksByIds').mockResolvedValue(new Map([[3, makeBook(3)]]));
|
||||
readingStateService.getRawState.mockResolvedValue({ EntitlementId: 'entitlement-3', CurrentBookmark: { ProgressPercent: 61 } });
|
||||
|
||||
const result = await (service as any).getPageFromSnapshot(7, 'tok', 'https://base', new Set([3]), new Map());
|
||||
const result = await (service as any).getPageFromSnapshot(7, 22, 'tok', 'https://base', new Set([3]), new Map());
|
||||
|
||||
expect(result.entitlements).toHaveLength(2);
|
||||
expect(result.entitlements[0]).toHaveProperty('ChangedProductMetadata');
|
||||
@@ -365,15 +506,14 @@ describe('KoboSyncService', () => {
|
||||
|
||||
it('getPageFromSnapshot sends a replacement entitlement for changed books still using legacy numeric ids', async () => {
|
||||
const db = makeDb({
|
||||
select: [[{ bookId: 3, pendingDelete: false, isNew: false }]],
|
||||
select: [[{ bookId: 3, pendingDelete: false, isNew: false, needsLegacyNumericRemoval: true }]],
|
||||
});
|
||||
db.query.koboLibrarySnapshots.findFirst.mockResolvedValue({ id: 22, userId: 7 });
|
||||
const service = makeService(db);
|
||||
vi.spyOn(service as any, 'fetchEligibleBooksByIds').mockResolvedValue(new Map([[3, { ...makeBook(3), needsLegacyNumericRemoval: true }]]));
|
||||
bookIdentityService.findByBookIds.mockResolvedValue(new Map([[3, makeIdentity(3, true)]]));
|
||||
vi.spyOn(service as any, 'fetchEligibleBooksByIds').mockResolvedValue(new Map([[3, makeBook(3)]]));
|
||||
bookIdentityService.findByBookIds.mockResolvedValue(new Map([[3, makeIdentity(3, false)]]));
|
||||
readingStateService.getRawState.mockResolvedValue({ EntitlementId: 'entitlement-3', CurrentBookmark: { ProgressPercent: 61 } });
|
||||
|
||||
const result = await (service as any).getPageFromSnapshot(7, 'tok', 'https://base', new Set([3]), new Map());
|
||||
const result = await (service as any).getPageFromSnapshot(7, 22, 'tok', 'https://base', new Set([3]), new Map());
|
||||
|
||||
expect(result.entitlements).toHaveLength(2);
|
||||
expect(result.entitlements[0]).toHaveProperty('ChangedEntitlement');
|
||||
@@ -385,6 +525,23 @@ describe('KoboSyncService', () => {
|
||||
expect(bookIdentityService.markLegacyNumericRemovalComplete).toHaveBeenCalledWith(7, [3]);
|
||||
});
|
||||
|
||||
it('sends both numeric and UUID removals when an undelivered legacy book becomes ineligible', async () => {
|
||||
const db = makeDb({
|
||||
select: [[{ bookId: 3, pendingDelete: true, isNew: false, needsLegacyNumericRemoval: true }]],
|
||||
});
|
||||
const service = makeService(db);
|
||||
vi.spyOn(service as any, 'fetchEligibleBooksByIds').mockResolvedValue(new Map());
|
||||
bookIdentityService.findByBookIds.mockResolvedValue(new Map([[3, makeIdentity(3, true)]]));
|
||||
|
||||
const result = await (service as any).getPageFromSnapshot(7, 22, 'tok', 'https://base', new Set(), new Map());
|
||||
const removedIds = result.entitlements.map(
|
||||
(entry: Record<string, { BookEntitlement: { Id: string } }>) => entry.ChangedEntitlement.BookEntitlement.Id,
|
||||
);
|
||||
|
||||
expect(removedIds).toEqual(['3', 'entitlement-3']);
|
||||
expect(bookIdentityService.markLegacyNumericRemovalComplete).toHaveBeenCalledWith(7, [3]);
|
||||
});
|
||||
|
||||
it('buildTagItems includes only currently-eligible books per collection', async () => {
|
||||
const db = makeDb({
|
||||
select: [
|
||||
@@ -722,6 +879,26 @@ describe('KoboSyncService', () => {
|
||||
expect(sql).toContain('is_new = true');
|
||||
});
|
||||
|
||||
it('keeps an undelivered legacy numeric removal as a pending delete when eligibility changes', async () => {
|
||||
const { fn: txExecute, captured } = makeTxExecute();
|
||||
const db = makeReconcileDb(txExecute);
|
||||
const service = makeService(db);
|
||||
|
||||
await (service as any).reconcileSnapshot(5, []);
|
||||
|
||||
const markPendingStmt = captured.find((stmt) => {
|
||||
const sql = extractSqlStrings(stmt).join(' ');
|
||||
return sql.includes('SET pending_delete = true') && sql.includes('sb.needs_legacy_numeric_removal = true');
|
||||
});
|
||||
const deleteStmt = captured.find((stmt) => {
|
||||
const sql = extractSqlStrings(stmt).join(' ');
|
||||
return sql.includes('DELETE FROM') && sql.includes('sb.needs_legacy_numeric_removal = false');
|
||||
});
|
||||
|
||||
expect(markPendingStmt).toBeDefined();
|
||||
expect(deleteStmt).toBeDefined();
|
||||
});
|
||||
|
||||
it('marks delivery changes as new entitlements and metadata-only changes as metadata updates', async () => {
|
||||
const { fn: txExecute, captured } = makeTxExecute();
|
||||
const db = makeReconcileDb(txExecute);
|
||||
@@ -897,6 +1074,7 @@ describe('KoboSyncService', () => {
|
||||
fileHash: 'file-hash',
|
||||
deliveryHash: expect.any(String),
|
||||
metadataHash: expect.any(String),
|
||||
needsLegacyNumericRemoval: true,
|
||||
},
|
||||
]);
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { createHash } from 'crypto';
|
||||
import { Inject, Injectable, Logger } from '@nestjs/common';
|
||||
import { SQL, and, asc, eq, inArray, or, sql } from 'drizzle-orm';
|
||||
import { Inject, Injectable, InternalServerErrorException } from '@nestjs/common';
|
||||
import { SQL, and, asc, eq, inArray, isNull, lte, or, sql } from 'drizzle-orm';
|
||||
import { NodePgDatabase } from 'drizzle-orm/node-postgres';
|
||||
|
||||
import { DB } from '../../../db/db.module';
|
||||
@@ -17,6 +17,7 @@ type Db = NodePgDatabase<typeof schema>;
|
||||
|
||||
const SYNC_PAGE_SIZE = 5;
|
||||
const SNAPSHOT_RECONCILE_BATCH_SIZE = 5000;
|
||||
const SNAPSHOT_CREATE_BATCH_SIZE = 5000;
|
||||
const TOKEN_PREFIX = 'PX.';
|
||||
|
||||
type EligibleSnapshotRow = {
|
||||
@@ -24,8 +25,25 @@ type EligibleSnapshotRow = {
|
||||
fileHash: string | null;
|
||||
deliveryHash: string;
|
||||
metadataHash: string;
|
||||
needsLegacyNumericRemoval?: boolean;
|
||||
};
|
||||
|
||||
type SnapshotSeedRow = {
|
||||
bookId: number;
|
||||
synced: false;
|
||||
pendingDelete: boolean;
|
||||
isNew: boolean;
|
||||
removedByDevice: false;
|
||||
needsLegacyNumericRemoval: boolean;
|
||||
fileHash: string | null;
|
||||
deliveryHash: string | null;
|
||||
metadataHash: string | null;
|
||||
};
|
||||
|
||||
type Snapshot = typeof schema.koboLibrarySnapshots.$inferSelect;
|
||||
|
||||
type LegacySnapshot = Pick<typeof schema.koboLegacyLibrarySnapshots.$inferSelect, 'id' | 'legacyDeviceCutoffAt'>;
|
||||
|
||||
type KoboDeliverySettings = {
|
||||
convertToKepub: boolean;
|
||||
forceEnableHyphenation: boolean;
|
||||
@@ -50,7 +68,6 @@ export interface KoboBookEntry {
|
||||
bookId: number;
|
||||
koboEntitlementId: string;
|
||||
koboCoverImageId: string;
|
||||
needsLegacyNumericRemoval: boolean;
|
||||
title: string;
|
||||
authors: string[];
|
||||
description: string | null;
|
||||
@@ -77,8 +94,6 @@ function encodeSyncToken(snapshotId: number): string {
|
||||
|
||||
@Injectable()
|
||||
export class KoboSyncService {
|
||||
private readonly logger = new Logger(KoboSyncService.name);
|
||||
|
||||
constructor(
|
||||
@Inject(DB) private readonly db: Db,
|
||||
private readonly bookAccessService: KoboBookAccessService,
|
||||
@@ -88,39 +103,49 @@ export class KoboSyncService {
|
||||
private readonly queryBuilder: BookQueryBuilder,
|
||||
) {}
|
||||
|
||||
async getDelta(userId: number, deviceToken: string, baseUrl: string): Promise<{ entitlements: unknown[]; hasMore: boolean; syncToken: string }> {
|
||||
const snapshot = await this.db.query.koboLibrarySnapshots.findFirst({
|
||||
where: eq(schema.koboLibrarySnapshots.userId, userId),
|
||||
});
|
||||
|
||||
const hadSnapshot = Boolean(snapshot);
|
||||
async getDelta(
|
||||
userId: number,
|
||||
deviceId: number,
|
||||
deviceToken: string,
|
||||
baseUrl: string,
|
||||
): Promise<{ entitlements: unknown[]; hasMore: boolean; syncToken: string }> {
|
||||
let snapshot = await this.findDeviceSnapshot(userId, deviceId);
|
||||
const hadDeviceSnapshot = snapshot ? true : await this.hasDeviceSnapshot(userId);
|
||||
const legacyNumericRemovalBookIds = snapshot ? new Set<number>() : await this.getLegacyNumericRemovalBookIds(userId, deviceId);
|
||||
const smartScopeMatchCache: SmartScopeMatchCache = new Map();
|
||||
const eligibleSnapshotRows = await this.fetchEligibleSnapshotRows(userId, hadSnapshot, smartScopeMatchCache);
|
||||
const eligibleSnapshotRows = await this.fetchEligibleSnapshotRows(userId, hadDeviceSnapshot, smartScopeMatchCache);
|
||||
|
||||
if (!snapshot) {
|
||||
await this.createSnapshot(userId, eligibleSnapshotRows);
|
||||
const created = await this.createSnapshot(userId, deviceId, eligibleSnapshotRows, legacyNumericRemovalBookIds);
|
||||
snapshot = created.snapshot;
|
||||
if (!created.created) {
|
||||
await this.reconcileSnapshot(snapshot.id, eligibleSnapshotRows);
|
||||
} else {
|
||||
await this.retireLegacySnapshotIfComplete(userId);
|
||||
}
|
||||
} else {
|
||||
await this.reconcileSnapshot(snapshot.id, eligibleSnapshotRows);
|
||||
}
|
||||
|
||||
return this.getPageFromSnapshot(userId, deviceToken, baseUrl, new Set(eligibleSnapshotRows.map((row) => row.bookId)), smartScopeMatchCache);
|
||||
return this.getPageFromSnapshot(
|
||||
userId,
|
||||
snapshot.id,
|
||||
deviceToken,
|
||||
baseUrl,
|
||||
new Set(eligibleSnapshotRows.map((row) => row.bookId)),
|
||||
smartScopeMatchCache,
|
||||
);
|
||||
}
|
||||
|
||||
async getBookMetadata(userId: number, bookId: number, deviceToken: string, baseUrl: string): Promise<unknown[]> {
|
||||
const snapshot = await this.db.query.koboLibrarySnapshots.findFirst({
|
||||
where: eq(schema.koboLibrarySnapshots.userId, userId),
|
||||
columns: { id: true },
|
||||
});
|
||||
const booksById = await this.fetchEligibleBooksByIds(userId, [bookId], Boolean(snapshot), new Map());
|
||||
const booksById = await this.fetchEligibleBooksByIds(userId, [bookId], await this.hasDeviceSnapshot(userId), new Map());
|
||||
const book = booksById.get(bookId) ?? null;
|
||||
if (!book) return [];
|
||||
return [this.buildBookMetadata(book, deviceToken, baseUrl)];
|
||||
}
|
||||
|
||||
async removeBookFromSync(userId: number, bookId: number): Promise<void> {
|
||||
const snapshot = await this.db.query.koboLibrarySnapshots.findFirst({
|
||||
where: eq(schema.koboLibrarySnapshots.userId, userId),
|
||||
});
|
||||
async removeBookFromSync(userId: number, deviceId: number, bookId: number): Promise<void> {
|
||||
const snapshot = await this.findDeviceSnapshot(userId, deviceId);
|
||||
if (!snapshot) return;
|
||||
|
||||
const row = await this.db.query.koboSnapshotBooks.findFirst({
|
||||
@@ -140,44 +165,173 @@ export class KoboSyncService {
|
||||
}
|
||||
}
|
||||
|
||||
async invalidateSnapshot(userId: number) {
|
||||
const start = Date.now();
|
||||
const snapshot = await this.db.query.koboLibrarySnapshots.findFirst({
|
||||
where: eq(schema.koboLibrarySnapshots.userId, userId),
|
||||
private async findDeviceSnapshot(userId: number, deviceId: number): Promise<Snapshot | undefined> {
|
||||
return this.db.query.koboLibrarySnapshots.findFirst({
|
||||
where: and(eq(schema.koboLibrarySnapshots.userId, userId), eq(schema.koboLibrarySnapshots.deviceId, deviceId)),
|
||||
});
|
||||
if (!snapshot) return;
|
||||
|
||||
const resetRows = await this.db
|
||||
.update(schema.koboSnapshotBooks)
|
||||
.set({ synced: false })
|
||||
.where(and(eq(schema.koboSnapshotBooks.snapshotId, snapshot.id), eq(schema.koboSnapshotBooks.synced, true)))
|
||||
.returning({ bookId: schema.koboSnapshotBooks.bookId });
|
||||
|
||||
this.logger.debug(
|
||||
`[kobo.snapshot.invalidate] [end] userId=${userId} snapshotId=${snapshot.id} durationMs=${Date.now() - start} resetSyncedCount=${resetRows.length} - snapshot invalidated`,
|
||||
);
|
||||
}
|
||||
|
||||
private async createSnapshot(userId: number, books: EligibleSnapshotRow[]) {
|
||||
const [snap] = await this.db.insert(schema.koboLibrarySnapshots).values({ userId }).returning();
|
||||
if (books.length > 0) {
|
||||
await this.db
|
||||
.insert(schema.koboSnapshotBooks)
|
||||
.values(
|
||||
books.map((b) => ({
|
||||
snapshotId: snap.id,
|
||||
bookId: b.bookId,
|
||||
synced: false,
|
||||
pendingDelete: false,
|
||||
isNew: true,
|
||||
removedByDevice: false,
|
||||
fileHash: b.fileHash,
|
||||
deliveryHash: b.deliveryHash,
|
||||
metadataHash: b.metadataHash,
|
||||
})),
|
||||
)
|
||||
.onConflictDoNothing();
|
||||
private async hasDeviceSnapshot(userId: number): Promise<boolean> {
|
||||
const snapshot = await this.db.query.koboLibrarySnapshots.findFirst({
|
||||
where: eq(schema.koboLibrarySnapshots.userId, userId),
|
||||
columns: { id: true },
|
||||
});
|
||||
return Boolean(snapshot);
|
||||
}
|
||||
|
||||
private async getLegacyNumericRemovalBookIds(userId: number, deviceId: number): Promise<Set<number>> {
|
||||
const legacySnapshot = await this.findLegacySnapshotForDevice(userId, deviceId);
|
||||
if (!legacySnapshot) return new Set();
|
||||
|
||||
const legacyBooks = await this.db
|
||||
.select({ bookId: schema.koboLegacySnapshotBooks.bookId })
|
||||
.from(schema.koboLegacySnapshotBooks)
|
||||
.where(eq(schema.koboLegacySnapshotBooks.snapshotId, legacySnapshot.id));
|
||||
for (let index = 0; index < legacyBooks.length; index += SNAPSHOT_CREATE_BATCH_SIZE) {
|
||||
await this.bookIdentityService.ensureForBooks(
|
||||
userId,
|
||||
legacyBooks.slice(index, index + SNAPSHOT_CREATE_BATCH_SIZE).map((book) => book.bookId),
|
||||
true,
|
||||
);
|
||||
}
|
||||
|
||||
return new Set(legacyBooks.map((book) => book.bookId));
|
||||
}
|
||||
|
||||
private async findLegacySnapshotForDevice(userId: number, deviceId: number): Promise<LegacySnapshot | undefined> {
|
||||
const [legacySnapshot] = await this.db
|
||||
.select({
|
||||
id: schema.koboLegacyLibrarySnapshots.id,
|
||||
legacyDeviceCutoffAt: schema.koboLegacyLibrarySnapshots.legacyDeviceCutoffAt,
|
||||
})
|
||||
.from(schema.koboLegacyLibrarySnapshots)
|
||||
.innerJoin(
|
||||
schema.koboDevices,
|
||||
and(
|
||||
eq(schema.koboDevices.id, deviceId),
|
||||
eq(schema.koboDevices.userId, userId),
|
||||
lte(schema.koboDevices.createdAt, schema.koboLegacyLibrarySnapshots.legacyDeviceCutoffAt),
|
||||
),
|
||||
)
|
||||
.where(eq(schema.koboLegacyLibrarySnapshots.userId, userId))
|
||||
.limit(1);
|
||||
return legacySnapshot;
|
||||
}
|
||||
|
||||
private async retireLegacySnapshotIfComplete(userId: number): Promise<void> {
|
||||
const legacySnapshot = await this.db.query.koboLegacyLibrarySnapshots.findFirst({
|
||||
where: eq(schema.koboLegacyLibrarySnapshots.userId, userId),
|
||||
columns: { id: true, legacyDeviceCutoffAt: true },
|
||||
});
|
||||
if (!legacySnapshot || (await this.hasUninitializedLegacyDevices(userId, legacySnapshot.legacyDeviceCutoffAt))) return;
|
||||
|
||||
await this.db
|
||||
.delete(schema.koboLegacyLibrarySnapshots)
|
||||
.where(and(eq(schema.koboLegacyLibrarySnapshots.id, legacySnapshot.id), eq(schema.koboLegacyLibrarySnapshots.userId, userId)));
|
||||
}
|
||||
|
||||
private async hasUninitializedLegacyDevices(userId: number, legacyDeviceCutoffAt?: Date): Promise<boolean> {
|
||||
let cutoffAt = legacyDeviceCutoffAt;
|
||||
if (!cutoffAt) {
|
||||
const legacySnapshot = await this.db.query.koboLegacyLibrarySnapshots.findFirst({
|
||||
where: eq(schema.koboLegacyLibrarySnapshots.userId, userId),
|
||||
columns: { legacyDeviceCutoffAt: true },
|
||||
});
|
||||
if (!legacySnapshot) return false;
|
||||
cutoffAt = legacySnapshot.legacyDeviceCutoffAt;
|
||||
}
|
||||
|
||||
const rows = await this.db
|
||||
.select({ id: schema.koboDevices.id })
|
||||
.from(schema.koboDevices)
|
||||
.leftJoin(schema.koboLibrarySnapshots, eq(schema.koboLibrarySnapshots.deviceId, schema.koboDevices.id))
|
||||
.where(and(eq(schema.koboDevices.userId, userId), lte(schema.koboDevices.createdAt, cutoffAt), isNull(schema.koboLibrarySnapshots.id)))
|
||||
.limit(1);
|
||||
return rows.length > 0;
|
||||
}
|
||||
|
||||
private async createSnapshot(
|
||||
userId: number,
|
||||
deviceId: number,
|
||||
eligibleBooks: EligibleSnapshotRow[],
|
||||
legacyNumericRemovalBookIds: ReadonlySet<number>,
|
||||
): Promise<{ snapshot: Snapshot; created: boolean }> {
|
||||
const seedRows: SnapshotSeedRow[] = [
|
||||
...eligibleBooks.map<SnapshotSeedRow>((book) => ({
|
||||
bookId: book.bookId,
|
||||
synced: false,
|
||||
pendingDelete: false,
|
||||
isNew: true,
|
||||
removedByDevice: false,
|
||||
needsLegacyNumericRemoval: legacyNumericRemovalBookIds.has(book.bookId) || (book.needsLegacyNumericRemoval ?? false),
|
||||
fileHash: book.fileHash,
|
||||
deliveryHash: book.deliveryHash,
|
||||
metadataHash: book.metadataHash,
|
||||
})),
|
||||
];
|
||||
|
||||
const created = await this.db.transaction(async (tx) => {
|
||||
const [snapshot] = await tx
|
||||
.insert(schema.koboLibrarySnapshots)
|
||||
.values({ userId, deviceId })
|
||||
.onConflictDoNothing({ target: schema.koboLibrarySnapshots.deviceId })
|
||||
.returning();
|
||||
if (!snapshot) return null;
|
||||
|
||||
for (let index = 0; index < seedRows.length; index += SNAPSHOT_CREATE_BATCH_SIZE) {
|
||||
const chunk = seedRows.slice(index, index + SNAPSHOT_CREATE_BATCH_SIZE);
|
||||
await tx
|
||||
.insert(schema.koboSnapshotBooks)
|
||||
.values(chunk.map((row) => ({ ...row, snapshotId: snapshot.id })))
|
||||
.onConflictDoNothing();
|
||||
}
|
||||
|
||||
const legacyBookIds = [...legacyNumericRemovalBookIds];
|
||||
for (let index = 0; index < legacyBookIds.length; index += SNAPSHOT_CREATE_BATCH_SIZE) {
|
||||
const chunk = legacyBookIds.slice(index, index + SNAPSHOT_CREATE_BATCH_SIZE);
|
||||
await tx
|
||||
.insert(schema.koboSnapshotBooks)
|
||||
.values(
|
||||
chunk.map((bookId) => ({
|
||||
snapshotId: snapshot.id,
|
||||
bookId,
|
||||
synced: false,
|
||||
pendingDelete: true,
|
||||
isNew: false,
|
||||
removedByDevice: false,
|
||||
needsLegacyNumericRemoval: true,
|
||||
fileHash: null,
|
||||
deliveryHash: null,
|
||||
metadataHash: null,
|
||||
})),
|
||||
)
|
||||
.onConflictDoUpdate({
|
||||
target: [schema.koboSnapshotBooks.snapshotId, schema.koboSnapshotBooks.bookId],
|
||||
set: { needsLegacyNumericRemoval: true },
|
||||
});
|
||||
}
|
||||
|
||||
await tx.execute(sql`
|
||||
INSERT INTO ${schema.koboSnapshotBooks}
|
||||
(snapshot_id, book_id, synced, pending_delete, is_new, removed_by_device, needs_legacy_numeric_removal, file_hash, delivery_hash, metadata_hash)
|
||||
SELECT ${snapshot.id}, entitlement.book_id, false, true, false, false, entitlement.needs_legacy_numeric_removal, NULL, NULL, NULL
|
||||
FROM ${schema.koboBookEntitlements} AS entitlement
|
||||
LEFT JOIN ${schema.koboSnapshotBooks} AS existing
|
||||
ON existing.snapshot_id = ${snapshot.id}
|
||||
AND existing.book_id = entitlement.book_id
|
||||
WHERE entitlement.user_id = ${userId}
|
||||
AND existing.book_id IS NULL
|
||||
ON CONFLICT DO NOTHING
|
||||
`);
|
||||
|
||||
return snapshot;
|
||||
});
|
||||
|
||||
if (created) return { snapshot: created, created: true };
|
||||
|
||||
const snapshot = await this.findDeviceSnapshot(userId, deviceId);
|
||||
if (!snapshot) throw new InternalServerErrorException('Failed to create Kobo device snapshot');
|
||||
return { snapshot, created: false };
|
||||
}
|
||||
|
||||
private async reconcileSnapshot(snapshotId: number, eligibleBooks: EligibleSnapshotRow[]) {
|
||||
@@ -187,25 +341,27 @@ export class KoboSyncService {
|
||||
book_id integer PRIMARY KEY,
|
||||
file_hash varchar(64),
|
||||
delivery_hash varchar(64) NOT NULL,
|
||||
metadata_hash varchar(64) NOT NULL
|
||||
metadata_hash varchar(64) NOT NULL,
|
||||
needs_legacy_numeric_removal boolean NOT NULL
|
||||
) ON COMMIT DROP
|
||||
`);
|
||||
|
||||
for (let index = 0; index < eligibleBooks.length; index += SNAPSHOT_RECONCILE_BATCH_SIZE) {
|
||||
const chunk = eligibleBooks.slice(index, index + SNAPSHOT_RECONCILE_BATCH_SIZE);
|
||||
const valueRows = sql.join(
|
||||
chunk.map((b) => sql`(${b.bookId}, ${b.fileHash}, ${b.deliveryHash}, ${b.metadataHash})`),
|
||||
chunk.map((b) => sql`(${b.bookId}, ${b.fileHash}, ${b.deliveryHash}, ${b.metadataHash}, ${b.needsLegacyNumericRemoval ?? false})`),
|
||||
sql`, `,
|
||||
);
|
||||
|
||||
await tx.execute(sql`
|
||||
INSERT INTO kobo_eligible_books_tmp (book_id, file_hash, delivery_hash, metadata_hash)
|
||||
INSERT INTO kobo_eligible_books_tmp (book_id, file_hash, delivery_hash, metadata_hash, needs_legacy_numeric_removal)
|
||||
VALUES ${valueRows}
|
||||
ON CONFLICT (book_id)
|
||||
DO UPDATE
|
||||
SET file_hash = EXCLUDED.file_hash,
|
||||
delivery_hash = EXCLUDED.delivery_hash,
|
||||
metadata_hash = EXCLUDED.metadata_hash
|
||||
metadata_hash = EXCLUDED.metadata_hash,
|
||||
needs_legacy_numeric_removal = EXCLUDED.needs_legacy_numeric_removal
|
||||
`);
|
||||
}
|
||||
|
||||
@@ -216,7 +372,7 @@ export class KoboSyncService {
|
||||
WHERE sb.snapshot_id = ${snapshotId}
|
||||
AND sb.pending_delete = false
|
||||
AND sb.removed_by_device = false
|
||||
AND sb.synced = true
|
||||
AND (sb.synced = true OR sb.needs_legacy_numeric_removal = true)
|
||||
AND NOT EXISTS (SELECT 1 FROM kobo_eligible_books_tmp e WHERE e.book_id = sb.book_id)
|
||||
`);
|
||||
|
||||
@@ -224,14 +380,14 @@ export class KoboSyncService {
|
||||
DELETE FROM ${schema.koboSnapshotBooks} AS sb
|
||||
WHERE sb.snapshot_id = ${snapshotId}
|
||||
AND sb.pending_delete = false
|
||||
AND (sb.removed_by_device = true OR sb.synced = false)
|
||||
AND (sb.removed_by_device = true OR (sb.synced = false AND sb.needs_legacy_numeric_removal = false))
|
||||
AND NOT EXISTS (SELECT 1 FROM kobo_eligible_books_tmp e WHERE e.book_id = sb.book_id)
|
||||
`);
|
||||
|
||||
await tx.execute(sql`
|
||||
INSERT INTO ${schema.koboSnapshotBooks}
|
||||
(snapshot_id, book_id, synced, pending_delete, is_new, removed_by_device, file_hash, delivery_hash, metadata_hash)
|
||||
SELECT ${snapshotId}, e.book_id, false, false, true, false, e.file_hash, e.delivery_hash, e.metadata_hash
|
||||
(snapshot_id, book_id, synced, pending_delete, is_new, removed_by_device, needs_legacy_numeric_removal, file_hash, delivery_hash, metadata_hash)
|
||||
SELECT ${snapshotId}, e.book_id, false, false, true, false, e.needs_legacy_numeric_removal, e.file_hash, e.delivery_hash, e.metadata_hash
|
||||
FROM kobo_eligible_books_tmp e
|
||||
LEFT JOIN ${schema.koboSnapshotBooks} sb
|
||||
ON sb.snapshot_id = ${snapshotId}
|
||||
@@ -320,23 +476,18 @@ export class KoboSyncService {
|
||||
|
||||
private async getPageFromSnapshot(
|
||||
userId: number,
|
||||
snapshotId: number,
|
||||
deviceToken: string,
|
||||
baseUrl: string,
|
||||
eligibleIds: Set<number>,
|
||||
smartScopeMatchCache: SmartScopeMatchCache,
|
||||
): Promise<{ entitlements: unknown[]; hasMore: boolean; syncToken: string }> {
|
||||
const snapshot = await this.db.query.koboLibrarySnapshots.findFirst({
|
||||
where: eq(schema.koboLibrarySnapshots.userId, userId),
|
||||
});
|
||||
|
||||
if (!snapshot) return { entitlements: [], hasMore: false, syncToken: encodeSyncToken(0) };
|
||||
|
||||
const syncToken = encodeSyncToken(snapshot.id);
|
||||
const syncToken = encodeSyncToken(snapshotId);
|
||||
|
||||
const pending = await this.db
|
||||
.select()
|
||||
.from(schema.koboSnapshotBooks)
|
||||
.where(and(eq(schema.koboSnapshotBooks.snapshotId, snapshot.id), eq(schema.koboSnapshotBooks.synced, false)))
|
||||
.where(and(eq(schema.koboSnapshotBooks.snapshotId, snapshotId), eq(schema.koboSnapshotBooks.synced, false)))
|
||||
.orderBy(asc(schema.koboSnapshotBooks.bookId))
|
||||
.limit(SYNC_PAGE_SIZE + 1);
|
||||
|
||||
@@ -352,7 +503,7 @@ export class KoboSyncService {
|
||||
await this.db
|
||||
.update(schema.koboSnapshotBooks)
|
||||
.set({ synced: true })
|
||||
.where(and(eq(schema.koboSnapshotBooks.snapshotId, snapshot.id), inArray(schema.koboSnapshotBooks.bookId, pageIds)));
|
||||
.where(and(eq(schema.koboSnapshotBooks.snapshotId, snapshotId), inArray(schema.koboSnapshotBooks.bookId, pageIds)));
|
||||
|
||||
const entitlements: unknown[] = [];
|
||||
const identitiesById = await this.bookIdentityService.findByBookIds(
|
||||
@@ -371,7 +522,7 @@ export class KoboSyncService {
|
||||
if (row.pendingDelete) {
|
||||
const identity = identitiesById.get(row.bookId) ?? null;
|
||||
if (identity) {
|
||||
if (identity.needsLegacyNumericRemoval) {
|
||||
if (row.needsLegacyNumericRemoval) {
|
||||
entitlements.push(this.buildRemovedEntitlement(row.bookId));
|
||||
legacyRemovalCompletedBookIds.push(row.bookId);
|
||||
}
|
||||
@@ -381,7 +532,7 @@ export class KoboSyncService {
|
||||
}
|
||||
await this.db
|
||||
.delete(schema.koboSnapshotBooks)
|
||||
.where(and(eq(schema.koboSnapshotBooks.snapshotId, snapshot.id), eq(schema.koboSnapshotBooks.bookId, row.bookId)));
|
||||
.where(and(eq(schema.koboSnapshotBooks.snapshotId, snapshotId), eq(schema.koboSnapshotBooks.bookId, row.bookId)));
|
||||
continue;
|
||||
}
|
||||
|
||||
@@ -391,8 +542,8 @@ export class KoboSyncService {
|
||||
const readingState = await this.readingStateService.getRawState(userId, row.bookId);
|
||||
const defaultReadingState = readingState ?? this.buildDefaultReadingState(book.koboEntitlementId);
|
||||
|
||||
if (row.isNew || book.needsLegacyNumericRemoval) {
|
||||
if (book.needsLegacyNumericRemoval) {
|
||||
if (row.isNew || row.needsLegacyNumericRemoval) {
|
||||
if (row.needsLegacyNumericRemoval) {
|
||||
entitlements.push(this.buildRemovedEntitlement(row.bookId));
|
||||
legacyRemovalCompletedBookIds.push(row.bookId);
|
||||
}
|
||||
@@ -420,11 +571,46 @@ export class KoboSyncService {
|
||||
}
|
||||
}
|
||||
|
||||
await this.bookIdentityService.markLegacyNumericRemovalComplete(userId, legacyRemovalCompletedBookIds);
|
||||
await this.completeLegacyNumericRemovals(userId, snapshotId, legacyRemovalCompletedBookIds);
|
||||
|
||||
return { entitlements, hasMore, syncToken };
|
||||
}
|
||||
|
||||
private async completeLegacyNumericRemovals(userId: number, snapshotId: number, bookIds: number[]): Promise<void> {
|
||||
const uniqueBookIds = [...new Set(bookIds)];
|
||||
if (uniqueBookIds.length === 0) return;
|
||||
|
||||
await this.db
|
||||
.update(schema.koboSnapshotBooks)
|
||||
.set({ needsLegacyNumericRemoval: false })
|
||||
.where(
|
||||
and(
|
||||
eq(schema.koboSnapshotBooks.snapshotId, snapshotId),
|
||||
inArray(schema.koboSnapshotBooks.bookId, uniqueBookIds),
|
||||
eq(schema.koboSnapshotBooks.needsLegacyNumericRemoval, true),
|
||||
),
|
||||
);
|
||||
|
||||
const stillPendingRows = await this.db
|
||||
.select({ bookId: schema.koboSnapshotBooks.bookId })
|
||||
.from(schema.koboSnapshotBooks)
|
||||
.innerJoin(schema.koboLibrarySnapshots, eq(schema.koboLibrarySnapshots.id, schema.koboSnapshotBooks.snapshotId))
|
||||
.where(
|
||||
and(
|
||||
eq(schema.koboLibrarySnapshots.userId, userId),
|
||||
inArray(schema.koboSnapshotBooks.bookId, uniqueBookIds),
|
||||
eq(schema.koboSnapshotBooks.needsLegacyNumericRemoval, true),
|
||||
),
|
||||
);
|
||||
|
||||
const stillPendingBookIds = new Set(stillPendingRows.map((row) => row.bookId));
|
||||
const completedBookIds = uniqueBookIds.filter((bookId) => !stillPendingBookIds.has(bookId));
|
||||
if (completedBookIds.length === 0) return;
|
||||
if (await this.hasUninitializedLegacyDevices(userId)) return;
|
||||
|
||||
await this.bookIdentityService.markLegacyNumericRemovalComplete(userId, completedBookIds);
|
||||
}
|
||||
|
||||
private async buildTagItems(userId: number, eligibleIds: Set<number>, smartScopeMatchCache: SmartScopeMatchCache): Promise<unknown[]> {
|
||||
const collections = await this.db.query.collections.findMany({
|
||||
where: and(eq(schema.collections.userId, userId), eq(schema.collections.syncToKobo, true)),
|
||||
@@ -826,6 +1012,7 @@ export class KoboSyncService {
|
||||
entitlementId: identity?.entitlementId ?? String(row.bookId),
|
||||
coverImageId,
|
||||
}),
|
||||
needsLegacyNumericRemoval: identity?.needsLegacyNumericRemoval ?? false,
|
||||
};
|
||||
});
|
||||
}
|
||||
@@ -916,7 +1103,6 @@ export class KoboSyncService {
|
||||
bookId: row.bookId,
|
||||
koboEntitlementId: identity.entitlementId,
|
||||
koboCoverImageId: coverImageId,
|
||||
needsLegacyNumericRemoval: identity.needsLegacyNumericRemoval,
|
||||
title: row.title ?? `Book ${row.bookId}`,
|
||||
authors,
|
||||
description: row.description,
|
||||
|
||||
@@ -0,0 +1,466 @@
|
||||
import { randomUUID } from 'crypto';
|
||||
import { readFile, readdir } from 'fs/promises';
|
||||
import { dirname, join } from 'path';
|
||||
import { fileURLToPath } from 'url';
|
||||
import { and, eq, inArray } from 'drizzle-orm';
|
||||
import { Client } from 'pg';
|
||||
|
||||
import * as schema from '../src/db/schema';
|
||||
import { createEpubFixture } from './e2e/reader-state-isolation/reader-state-isolation-fixture-builder';
|
||||
import {
|
||||
authHeader,
|
||||
closeReaderStateIsolationE2EContext,
|
||||
createLibraryWithFolder,
|
||||
createReaderStateIsolationE2EContext,
|
||||
locateBookByAbsolutePath,
|
||||
triggerAndWaitForLibraryScan,
|
||||
type CreatedLibrary,
|
||||
type ReaderStateIsolationE2EContext,
|
||||
} from './e2e/reader-state-isolation/reader-state-isolation-harness';
|
||||
|
||||
type KoboDevice = { id: number; token: string };
|
||||
type SyncResponse = { entries: unknown[]; hasMore: boolean; syncToken: string };
|
||||
|
||||
const MIGRATIONS_DIRECTORY = join(dirname(fileURLToPath(import.meta.url)), '../src/db/migrations');
|
||||
|
||||
function escapeIdentifier(identifier: string): string {
|
||||
return `"${identifier.replace(/"/g, '""')}"`;
|
||||
}
|
||||
|
||||
async function applyMigrations(client: Client, filenames: string[]): Promise<void> {
|
||||
for (const filename of filenames) {
|
||||
await client.query(await readFile(join(MIGRATIONS_DIRECTORY, filename), 'utf8'));
|
||||
}
|
||||
}
|
||||
|
||||
function entitlementIds(entries: unknown[]): string[] {
|
||||
return entries.flatMap((entry) => {
|
||||
const value = entry as Record<string, Record<string, Record<string, string>>>;
|
||||
const bookEntitlement = value.NewEntitlement?.BookEntitlement ?? value.ChangedProductMetadata?.BookEntitlement;
|
||||
return bookEntitlement?.Id ? [bookEntitlement.Id] : [];
|
||||
});
|
||||
}
|
||||
|
||||
function newEntitlementIds(entries: unknown[]): string[] {
|
||||
return entries.flatMap((entry) => {
|
||||
const value = entry as Record<string, Record<string, Record<string, string>>>;
|
||||
const bookEntitlement = value.NewEntitlement?.BookEntitlement;
|
||||
return bookEntitlement?.Id ? [bookEntitlement.Id] : [];
|
||||
});
|
||||
}
|
||||
|
||||
function removedEntitlementIds(entries: unknown[]): string[] {
|
||||
return entries.flatMap((entry) => {
|
||||
const value = entry as Record<string, Record<string, Record<string, string>>>;
|
||||
const bookEntitlement = value.ChangedEntitlement?.BookEntitlement;
|
||||
return bookEntitlement?.Id ? [bookEntitlement.Id] : [];
|
||||
});
|
||||
}
|
||||
|
||||
describe('Kobo device snapshot migration (e2e)', { timeout: 180_000 }, () => {
|
||||
it('retains populated legacy snapshot data for the per-device transition bridge', async () => {
|
||||
const baseUrl = new URL(process.env.DATABASE_URL!);
|
||||
const databaseName = `kobo_snapshot_${randomUUID().replace(/-/g, '').slice(0, 12)}_e2e`;
|
||||
const targetUrl = new URL(baseUrl);
|
||||
targetUrl.pathname = `/${databaseName}`;
|
||||
const adminUrl = new URL(baseUrl);
|
||||
adminUrl.pathname = '/postgres';
|
||||
|
||||
const admin = new Client({ connectionString: adminUrl.toString() });
|
||||
let target: Client | undefined;
|
||||
await admin.connect();
|
||||
try {
|
||||
await admin.query(`CREATE DATABASE ${escapeIdentifier(databaseName)}`);
|
||||
target = new Client({ connectionString: targetUrl.toString() });
|
||||
await target.connect();
|
||||
await target.query('CREATE EXTENSION IF NOT EXISTS "uuid-ossp"');
|
||||
await target.query('CREATE EXTENSION IF NOT EXISTS pg_trgm');
|
||||
await target.query('CREATE EXTENSION IF NOT EXISTS vector');
|
||||
|
||||
const migrationFiles = (await readdir(MIGRATIONS_DIRECTORY)).filter((filename) => filename.endsWith('.sql')).sort();
|
||||
await applyMigrations(
|
||||
target,
|
||||
migrationFiles.filter((filename) => filename < '0045_kobo-device-owner-key.sql'),
|
||||
);
|
||||
|
||||
const userId = (
|
||||
await target.query<{ id: number }>(
|
||||
"INSERT INTO users (username, name, password_hash) VALUES ('kobo-migration-legacy', 'Kobo Migration Legacy', 'hash') RETURNING id",
|
||||
)
|
||||
).rows[0]!.id;
|
||||
const libraryId = (await target.query<{ id: number }>("INSERT INTO libraries (name) VALUES ('Kobo Migration Library') RETURNING id")).rows[0]!
|
||||
.id;
|
||||
const folderId = (
|
||||
await target.query<{ id: number }>("INSERT INTO library_folders (library_id, path) VALUES ($1, '/kobo-migration') RETURNING id", [libraryId])
|
||||
).rows[0]!.id;
|
||||
const bookId = (
|
||||
await target.query<{ id: number }>(
|
||||
"INSERT INTO books (library_id, library_folder_id, folder_path) VALUES ($1, $2, '/kobo-migration/book') RETURNING id",
|
||||
[libraryId, folderId],
|
||||
)
|
||||
).rows[0]!.id;
|
||||
await target.query("INSERT INTO kobo_devices (user_id, name, token) VALUES ($1, 'Legacy Kobo', 'legacy-kobo-migration-token')", [userId]);
|
||||
const snapshotId = (await target.query<{ id: number }>('INSERT INTO kobo_library_snapshots (user_id) VALUES ($1) RETURNING id', [userId]))
|
||||
.rows[0]!.id;
|
||||
await target.query('INSERT INTO kobo_snapshot_books (snapshot_id, book_id, synced) VALUES ($1, $2, true)', [snapshotId, bookId]);
|
||||
|
||||
await applyMigrations(target, ['0045_kobo-device-owner-key.sql', '0046_kobo-device-snapshots.sql']);
|
||||
|
||||
const [bridge] = (
|
||||
await target.query<{ legacyDeviceCutoffAt: Date; legacyBookCount: string }>(
|
||||
`SELECT legacy_device_cutoff_at AS "legacyDeviceCutoffAt", count(sb.book_id) AS "legacyBookCount"
|
||||
FROM kobo_library_snapshots AS s
|
||||
LEFT JOIN kobo_snapshot_books AS sb ON sb.snapshot_id = s.id
|
||||
WHERE s.user_id = $1
|
||||
GROUP BY s.legacy_device_cutoff_at`,
|
||||
[userId],
|
||||
)
|
||||
).rows;
|
||||
const [deviceSnapshotCount] = (await target.query<{ count: string }>('SELECT count(*) FROM kobo_device_library_snapshots')).rows;
|
||||
|
||||
expect(bridge?.legacyDeviceCutoffAt).toBeInstanceOf(Date);
|
||||
expect(bridge?.legacyBookCount).toBe('1');
|
||||
expect(deviceSnapshotCount?.count).toBe('0');
|
||||
} finally {
|
||||
await target?.end();
|
||||
await admin.query(`DROP DATABASE IF EXISTS ${escapeIdentifier(databaseName)} WITH (FORCE)`);
|
||||
await admin.end();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('Kobo multi-device library sync (e2e)', { timeout: 180_000 }, () => {
|
||||
let ctx!: ReaderStateIsolationE2EContext;
|
||||
let library!: CreatedLibrary;
|
||||
let userId!: number;
|
||||
let bookIds!: number[];
|
||||
let collectionId!: number;
|
||||
let deviceA!: KoboDevice;
|
||||
let deviceB!: KoboDevice;
|
||||
|
||||
function syncUrl(device: KoboDevice): string {
|
||||
return `/api/v1/kobo/${device.token}/v1/library/sync`;
|
||||
}
|
||||
|
||||
async function sync(device: KoboDevice, syncToken?: string): Promise<SyncResponse> {
|
||||
const response = await ctx.app.inject({
|
||||
method: 'GET',
|
||||
url: syncUrl(device),
|
||||
headers: syncToken ? { 'x-kobo-synctoken': syncToken } : undefined,
|
||||
});
|
||||
expect(response.statusCode).toBe(200);
|
||||
|
||||
const responseSyncToken = response.headers['x-kobo-synctoken'];
|
||||
expect(typeof responseSyncToken).toBe('string');
|
||||
return {
|
||||
entries: response.json() as unknown[],
|
||||
hasMore: response.headers['x-kobo-sync'] === 'continue',
|
||||
syncToken: responseSyncToken as string,
|
||||
};
|
||||
}
|
||||
|
||||
async function drainEntries(device: KoboDevice): Promise<unknown[]> {
|
||||
const delivered: unknown[] = [];
|
||||
let syncToken: string | undefined;
|
||||
|
||||
for (let page = 0; page < 10; page += 1) {
|
||||
const response = await sync(device, syncToken);
|
||||
delivered.push(...response.entries);
|
||||
if (!response.hasMore) return delivered;
|
||||
syncToken = response.syncToken;
|
||||
}
|
||||
|
||||
throw new Error(`Kobo device ${device.id} did not finish syncing within ten pages`);
|
||||
}
|
||||
|
||||
async function drain(device: KoboDevice): Promise<string[]> {
|
||||
return entitlementIds(await drainEntries(device));
|
||||
}
|
||||
|
||||
async function createDevice(name: string): Promise<KoboDevice> {
|
||||
const response = await ctx.app.inject({
|
||||
method: 'POST',
|
||||
url: '/api/v1/kobo/devices',
|
||||
headers: authHeader(ctx.adminToken),
|
||||
payload: { name },
|
||||
});
|
||||
expect([200, 201]).toContain(response.statusCode);
|
||||
const body = response.json() as KoboDevice;
|
||||
expect(body.id).toBeTypeOf('number');
|
||||
expect(body.token).toEqual(expect.any(String));
|
||||
return body;
|
||||
}
|
||||
|
||||
beforeAll(async () => {
|
||||
ctx = await createReaderStateIsolationE2EContext();
|
||||
library = await createLibraryWithFolder(ctx, { name: `kobo-multi-device-${randomUUID()}` });
|
||||
|
||||
const paths = await Promise.all(
|
||||
Array.from({ length: 6 }, (_, index) =>
|
||||
createEpubFixture(library.folderPath, `kobo-multi-device-${index + 1}.epub`, {
|
||||
title: `Kobo Multi Device ${index + 1}`,
|
||||
uid: `urn:uuid:${randomUUID()}`,
|
||||
}),
|
||||
),
|
||||
);
|
||||
await triggerAndWaitForLibraryScan(ctx, library.libraryId);
|
||||
bookIds = (await Promise.all(paths.map((path) => locateBookByAbsolutePath(ctx, path)))).map((book) => book.bookId);
|
||||
|
||||
const [user] = await ctx.db.select({ id: schema.users.id }).from(schema.users).where(eq(schema.users.username, 'reader-state-e2e-admin'));
|
||||
userId = user!.id;
|
||||
|
||||
const collectionResponse = await ctx.app.inject({
|
||||
method: 'POST',
|
||||
url: '/api/v1/collections',
|
||||
headers: authHeader(ctx.adminToken),
|
||||
payload: { name: `Kobo Multi Device ${randomUUID().slice(0, 8)}`, icon: 'book', syncToKobo: true },
|
||||
});
|
||||
expect([200, 201]).toContain(collectionResponse.statusCode);
|
||||
collectionId = (collectionResponse.json() as { id: number }).id;
|
||||
|
||||
const addBooksResponse = await ctx.app.inject({
|
||||
method: 'POST',
|
||||
url: `/api/v1/collections/${collectionId}/books`,
|
||||
headers: authHeader(ctx.adminToken),
|
||||
payload: { bookIds },
|
||||
});
|
||||
expect([200, 201]).toContain(addBooksResponse.statusCode);
|
||||
|
||||
deviceA = await createDevice('Kobo A');
|
||||
deviceB = await createDevice('Kobo B');
|
||||
}, 180_000);
|
||||
|
||||
afterAll(async () => {
|
||||
if (ctx) await closeReaderStateIsolationE2EContext(ctx);
|
||||
});
|
||||
|
||||
it('delivers every initial page independently when two devices interleave', async () => {
|
||||
const aFirst = await sync(deviceA);
|
||||
const bFirst = await sync(deviceB);
|
||||
expect(aFirst.hasMore).toBe(true);
|
||||
expect(bFirst.hasMore).toBe(true);
|
||||
expect(entitlementIds(aFirst.entries)).toHaveLength(5);
|
||||
expect(entitlementIds(bFirst.entries)).toHaveLength(5);
|
||||
|
||||
const aSecond = await sync(deviceA, aFirst.syncToken);
|
||||
const bSecond = await sync(deviceB, bFirst.syncToken);
|
||||
expect(aSecond.hasMore).toBe(false);
|
||||
expect(bSecond.hasMore).toBe(false);
|
||||
|
||||
const expectedIds = await ctx.db
|
||||
.select({ entitlementId: schema.koboBookEntitlements.entitlementId })
|
||||
.from(schema.koboBookEntitlements)
|
||||
.where(and(eq(schema.koboBookEntitlements.userId, userId), inArray(schema.koboBookEntitlements.bookId, bookIds)));
|
||||
const expected = expectedIds.map((row) => row.entitlementId).sort();
|
||||
expect([...entitlementIds([...aFirst.entries, ...aSecond.entries])].sort()).toEqual(expected);
|
||||
expect([...entitlementIds([...bFirst.entries, ...bSecond.entries])].sort()).toEqual(expected);
|
||||
|
||||
const snapshots = await ctx.db
|
||||
.select({ id: schema.koboLibrarySnapshots.id, deviceId: schema.koboLibrarySnapshots.deviceId })
|
||||
.from(schema.koboLibrarySnapshots)
|
||||
.where(eq(schema.koboLibrarySnapshots.userId, userId));
|
||||
expect(snapshots.map((snapshot) => snapshot.deviceId).sort()).toEqual([deviceA.id, deviceB.id].sort());
|
||||
|
||||
const rows = await ctx.db
|
||||
.select({ snapshotId: schema.koboSnapshotBooks.snapshotId, synced: schema.koboSnapshotBooks.synced })
|
||||
.from(schema.koboSnapshotBooks)
|
||||
.where(
|
||||
inArray(
|
||||
schema.koboSnapshotBooks.snapshotId,
|
||||
snapshots.map((snapshot) => snapshot.id),
|
||||
),
|
||||
);
|
||||
expect(rows).toHaveLength(12);
|
||||
expect(rows.every((row) => row.synced)).toBe(true);
|
||||
});
|
||||
|
||||
it('fans metadata and delivery changes out to every device independently', async () => {
|
||||
const changedBookId = bookIds[0]!;
|
||||
await ctx.db
|
||||
.update(schema.bookMetadata)
|
||||
.set({ title: 'Kobo Multi Device Updated', updatedAt: new Date() })
|
||||
.where(eq(schema.bookMetadata.bookId, changedBookId));
|
||||
|
||||
const [changedIdentity] = await ctx.db
|
||||
.select({ entitlementId: schema.koboBookEntitlements.entitlementId })
|
||||
.from(schema.koboBookEntitlements)
|
||||
.where(and(eq(schema.koboBookEntitlements.userId, userId), eq(schema.koboBookEntitlements.bookId, changedBookId)));
|
||||
|
||||
const aMetadata = await sync(deviceA);
|
||||
const bMetadata = await sync(deviceB);
|
||||
expect(entitlementIds(aMetadata.entries)).toEqual([changedIdentity!.entitlementId]);
|
||||
expect(entitlementIds(bMetadata.entries)).toEqual([changedIdentity!.entitlementId]);
|
||||
expect((aMetadata.entries[0] as Record<string, unknown>).ChangedProductMetadata).toBeDefined();
|
||||
expect((bMetadata.entries[0] as Record<string, unknown>).ChangedProductMetadata).toBeDefined();
|
||||
|
||||
const settingsResponse = await ctx.app.inject({
|
||||
method: 'PATCH',
|
||||
url: '/api/v1/kobo/settings',
|
||||
headers: authHeader(ctx.adminToken),
|
||||
payload: { forceEnableHyphenation: true },
|
||||
});
|
||||
expect(settingsResponse.statusCode).toBe(200);
|
||||
|
||||
const [aDeliveryEntries, bDeliveryEntries, expectedIdentityRows] = await Promise.all([
|
||||
drainEntries(deviceA),
|
||||
drainEntries(deviceB),
|
||||
ctx.db
|
||||
.select({ entitlementId: schema.koboBookEntitlements.entitlementId })
|
||||
.from(schema.koboBookEntitlements)
|
||||
.where(and(eq(schema.koboBookEntitlements.userId, userId), inArray(schema.koboBookEntitlements.bookId, bookIds))),
|
||||
]);
|
||||
const expectedIds = expectedIdentityRows.map((row) => row.entitlementId).sort();
|
||||
expect(newEntitlementIds(aDeliveryEntries).sort()).toEqual(expectedIds);
|
||||
expect(newEntitlementIds(bDeliveryEntries).sort()).toEqual(expectedIds);
|
||||
});
|
||||
|
||||
it('keeps new-device delivery and device-local removal isolated', async () => {
|
||||
const deviceC = await createDevice('Kobo C');
|
||||
const deviceCDelivered = await drain(deviceC);
|
||||
const expectedIdentityRows = await ctx.db
|
||||
.select({ entitlementId: schema.koboBookEntitlements.entitlementId })
|
||||
.from(schema.koboBookEntitlements)
|
||||
.where(and(eq(schema.koboBookEntitlements.userId, userId), inArray(schema.koboBookEntitlements.bookId, bookIds)));
|
||||
expect(deviceCDelivered.sort()).toEqual(expectedIdentityRows.map((row) => row.entitlementId).sort());
|
||||
|
||||
const snapshotsBeforeDelete = await ctx.db
|
||||
.select({ id: schema.koboLibrarySnapshots.id, deviceId: schema.koboLibrarySnapshots.deviceId })
|
||||
.from(schema.koboLibrarySnapshots)
|
||||
.where(eq(schema.koboLibrarySnapshots.userId, userId));
|
||||
const aAndBRows = await ctx.db
|
||||
.select({ snapshotId: schema.koboSnapshotBooks.snapshotId, synced: schema.koboSnapshotBooks.synced })
|
||||
.from(schema.koboSnapshotBooks)
|
||||
.where(
|
||||
inArray(
|
||||
schema.koboSnapshotBooks.snapshotId,
|
||||
snapshotsBeforeDelete
|
||||
.filter((snapshot) => snapshot.deviceId === deviceA.id || snapshot.deviceId === deviceB.id)
|
||||
.map((snapshot) => snapshot.id),
|
||||
),
|
||||
);
|
||||
expect(aAndBRows).toHaveLength(12);
|
||||
expect(aAndBRows.every((row) => row.synced)).toBe(true);
|
||||
|
||||
const [targetIdentity] = await ctx.db
|
||||
.select({ entitlementId: schema.koboBookEntitlements.entitlementId })
|
||||
.from(schema.koboBookEntitlements)
|
||||
.where(and(eq(schema.koboBookEntitlements.userId, userId), eq(schema.koboBookEntitlements.bookId, bookIds[0]!)));
|
||||
const deleteResponse = await ctx.app.inject({
|
||||
method: 'DELETE',
|
||||
url: `/api/v1/kobo/${deviceA.token}/v1/library/${targetIdentity!.entitlementId}`,
|
||||
});
|
||||
expect(deleteResponse.statusCode).toBe(200);
|
||||
|
||||
const deviceState = await ctx.db
|
||||
.select({
|
||||
deviceId: schema.koboLibrarySnapshots.deviceId,
|
||||
removedByDevice: schema.koboSnapshotBooks.removedByDevice,
|
||||
synced: schema.koboSnapshotBooks.synced,
|
||||
})
|
||||
.from(schema.koboSnapshotBooks)
|
||||
.innerJoin(schema.koboLibrarySnapshots, eq(schema.koboLibrarySnapshots.id, schema.koboSnapshotBooks.snapshotId))
|
||||
.where(and(eq(schema.koboLibrarySnapshots.userId, userId), eq(schema.koboSnapshotBooks.bookId, bookIds[0]!)));
|
||||
expect(deviceState.find((row) => row.deviceId === deviceA.id)).toMatchObject({ removedByDevice: true, synced: true });
|
||||
expect(deviceState.find((row) => row.deviceId === deviceB.id)).toMatchObject({ removedByDevice: false, synced: true });
|
||||
expect(deviceState.find((row) => row.deviceId === deviceC.id)).toMatchObject({ removedByDevice: false, synced: true });
|
||||
|
||||
const aReadded = await sync(deviceA);
|
||||
expect(entitlementIds(aReadded.entries)).toContain(targetIdentity!.entitlementId);
|
||||
const bAfterDeviceARemoval = await sync(deviceB);
|
||||
expect(entitlementIds(bAfterDeviceARemoval.entries)).not.toContain(targetIdentity!.entitlementId);
|
||||
|
||||
const revokeResponse = await ctx.app.inject({
|
||||
method: 'DELETE',
|
||||
url: `/api/v1/kobo/devices/${deviceC.id}`,
|
||||
headers: authHeader(ctx.adminToken),
|
||||
});
|
||||
expect(revokeResponse.statusCode).toBe(204);
|
||||
|
||||
const revokedSnapshot = await ctx.db.query.koboLibrarySnapshots.findFirst({
|
||||
where: eq(schema.koboLibrarySnapshots.deviceId, deviceC.id),
|
||||
});
|
||||
expect(revokedSnapshot).toBeUndefined();
|
||||
|
||||
const remainingSnapshots = await ctx.db
|
||||
.select({ deviceId: schema.koboLibrarySnapshots.deviceId })
|
||||
.from(schema.koboLibrarySnapshots)
|
||||
.where(eq(schema.koboLibrarySnapshots.userId, userId));
|
||||
expect(remainingSnapshots.map((snapshot) => snapshot.deviceId).sort()).toEqual([deviceA.id, deviceB.id].sort());
|
||||
});
|
||||
|
||||
it('bridges every pre-upgrade device even after the global numeric-removal marker was cleared', async () => {
|
||||
const [legacyEligiblePath, legacyRemovedPath] = await Promise.all([
|
||||
createEpubFixture(library.folderPath, `kobo-legacy-bridge-${randomUUID()}.epub`, {
|
||||
title: 'Kobo Legacy Bridge',
|
||||
uid: `urn:uuid:${randomUUID()}`,
|
||||
}),
|
||||
createEpubFixture(library.folderPath, `kobo-legacy-removed-${randomUUID()}.epub`, {
|
||||
title: 'Kobo Legacy Removed',
|
||||
uid: `urn:uuid:${randomUUID()}`,
|
||||
}),
|
||||
]);
|
||||
await triggerAndWaitForLibraryScan(ctx, library.libraryId);
|
||||
const [legacyEligibleBookId, legacyRemovedBookId] = await Promise.all([
|
||||
locateBookByAbsolutePath(ctx, legacyEligiblePath).then((book) => book.bookId),
|
||||
locateBookByAbsolutePath(ctx, legacyRemovedPath).then((book) => book.bookId),
|
||||
]);
|
||||
const addBookResponse = await ctx.app.inject({
|
||||
method: 'POST',
|
||||
url: `/api/v1/collections/${collectionId}/books`,
|
||||
headers: authHeader(ctx.adminToken),
|
||||
payload: { bookIds: [legacyEligibleBookId] },
|
||||
});
|
||||
expect([200, 201]).toContain(addBookResponse.statusCode);
|
||||
|
||||
const legacyDeviceA = await createDevice('Kobo Legacy Bridge A');
|
||||
const legacyDeviceB = await createDevice('Kobo Legacy Bridge B');
|
||||
await ctx.db.insert(schema.koboBookEntitlements).values([
|
||||
{ userId, bookId: legacyEligibleBookId, needsLegacyNumericRemoval: false },
|
||||
{ userId, bookId: legacyRemovedBookId, needsLegacyNumericRemoval: false },
|
||||
]);
|
||||
const [legacySnapshot] = await ctx.db
|
||||
.insert(schema.koboLegacyLibrarySnapshots)
|
||||
.values({ userId, legacyDeviceCutoffAt: new Date(Date.now() + 60_000) })
|
||||
.returning({ id: schema.koboLegacyLibrarySnapshots.id });
|
||||
await ctx.db.insert(schema.koboLegacySnapshotBooks).values([
|
||||
{ snapshotId: legacySnapshot!.id, bookId: legacyEligibleBookId, synced: true },
|
||||
{ snapshotId: legacySnapshot!.id, bookId: legacyRemovedBookId, synced: true },
|
||||
]);
|
||||
|
||||
const firstDeviceEntries = await drainEntries(legacyDeviceA);
|
||||
const markerStateAfterFirstDevice = await ctx.db
|
||||
.select({ bookId: schema.koboBookEntitlements.bookId, needsLegacyNumericRemoval: schema.koboBookEntitlements.needsLegacyNumericRemoval })
|
||||
.from(schema.koboBookEntitlements)
|
||||
.where(
|
||||
and(eq(schema.koboBookEntitlements.userId, userId), inArray(schema.koboBookEntitlements.bookId, [legacyEligibleBookId, legacyRemovedBookId])),
|
||||
);
|
||||
expect(markerStateAfterFirstDevice.every((row) => !row.needsLegacyNumericRemoval)).toBe(true);
|
||||
|
||||
const secondDeviceEntries = await drainEntries(legacyDeviceB);
|
||||
const identityRows = await ctx.db
|
||||
.select({ bookId: schema.koboBookEntitlements.bookId, entitlementId: schema.koboBookEntitlements.entitlementId })
|
||||
.from(schema.koboBookEntitlements)
|
||||
.where(
|
||||
and(eq(schema.koboBookEntitlements.userId, userId), inArray(schema.koboBookEntitlements.bookId, [legacyEligibleBookId, legacyRemovedBookId])),
|
||||
);
|
||||
const identitiesByBookId = new Map(identityRows.map((row) => [row.bookId, row.entitlementId]));
|
||||
const legacyEligibleEntitlementId = identitiesByBookId.get(legacyEligibleBookId);
|
||||
const legacyRemovedEntitlementId = identitiesByBookId.get(legacyRemovedBookId);
|
||||
if (!legacyEligibleEntitlementId || !legacyRemovedEntitlementId) {
|
||||
throw new Error('Legacy bridge did not create entitlement identities');
|
||||
}
|
||||
|
||||
for (const deliveredEntries of [firstDeviceEntries, secondDeviceEntries]) {
|
||||
expect(removedEntitlementIds(deliveredEntries)).toEqual(
|
||||
expect.arrayContaining([String(legacyEligibleBookId), String(legacyRemovedBookId), legacyRemovedEntitlementId]),
|
||||
);
|
||||
expect(entitlementIds(deliveredEntries)).toContain(legacyEligibleEntitlementId);
|
||||
expect(entitlementIds(deliveredEntries)).not.toContain(legacyRemovedEntitlementId);
|
||||
}
|
||||
|
||||
const retiredLegacySnapshot = await ctx.db.query.koboLegacyLibrarySnapshots.findFirst({
|
||||
where: eq(schema.koboLegacyLibrarySnapshots.userId, userId),
|
||||
});
|
||||
expect(retiredLegacySnapshot).toBeUndefined();
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user