db(annotations): let a deleted audio bookmark position be bookmarked again

Audio bookmark deletes are soft, but the unique index on (user, book, position_seconds) still
covered tombstones, so the insert conflicted and the audiobook bookmark route answered 409 for
any position that had ever been bookmarked and deleted. Limit the index to live rows. The CFI
index is left as it is because KOReader bookmark revival relies on tombstones keeping their CFI.

The new predicate only narrows the index, so existing rows cannot violate it.
This commit is contained in:
neon
2026-09-28 22:26:10 +00:00
parent c76d9c01f9
commit fd480b7f2b
5 changed files with 22509 additions and 2 deletions
@@ -0,0 +1,2 @@
DROP INDEX "bookmarks_user_book_pos_uidx";--> statement-breakpoint
CREATE UNIQUE INDEX "bookmarks_user_book_pos_uidx" ON "bookmarks" USING btree ("user_id","book_id","position_seconds") WHERE "bookmarks"."position_seconds" is not null and "bookmarks"."cfi" is null and "bookmarks"."deleted_at" is null;
File diff suppressed because it is too large Load Diff
@@ -701,6 +701,13 @@
"when": 1790446233976,
"tag": "0099_fractional_finished_threshold",
"breakpoints": true
},
{
"idx": 100,
"version": "7",
"when": 1790634252132,
"tag": "0100_audio_bookmark_position_ignores_tombstones",
"breakpoints": true
}
]
}
+11 -1
View File
@@ -1,4 +1,4 @@
import { getTableConfig } from 'drizzle-orm/pg-core';
import { PgDialect, getTableConfig } from 'drizzle-orm/pg-core';
import {
annotations,
@@ -105,6 +105,16 @@ describe('reader schema', () => {
expect(annotations.style.default).toBe('highlight');
});
it('frees a deleted audio bookmark position but keeps CFI tombstones unique for KOReader revival', () => {
const dialect = new PgDialect();
const predicates = new Map(getTableConfig(bookmarks).indexes.map((idx) => [idx.config.name, idx.config.where]));
const positionPredicate = dialect.sqlToQuery(predicates.get('bookmarks_user_book_pos_uidx')!).sql;
const cfiPredicate = dialect.sqlToQuery(predicates.get('bookmarks_user_book_cfi_uidx')!).sql;
expect(positionPredicate).toContain('"deleted_at" is null');
expect(cfiPredicate).not.toContain('deleted_at');
});
it('keeps reader preference uniqueness scoped to user and format/file', () => {
const defaultConfig = getTableConfig(readerDefaultPreferences);
const perBookConfig = getTableConfig(readerPreferences);
+1 -1
View File
@@ -447,7 +447,7 @@ export const bookmarks = pgTable(
.where(sql`${t.cfi} is not null`),
uniqueIndex('bookmarks_user_book_pos_uidx')
.on(t.userId, t.bookId, t.positionSeconds)
.where(sql`${t.positionSeconds} is not null and ${t.cfi} is null`),
.where(sql`${t.positionSeconds} is not null and ${t.cfi} is null and ${t.deletedAt} is null`),
check('bookmarks_origin_chk', sql`${t.origin} in ('web', 'koreader')`),
],
);