fix(sync): mint reincarnation token on re-import of custom fonts/textures (#4410) (#4416)

Custom fonts (and textures) disappeared a few seconds after opening a book
when logged into cloud sync. Under the replica layer's CRDT remove-wins
semantics, deleting a font writes a server-side tombstone that a plain
field upsert cannot revive — only a reincarnation token whose HLC beats
the tombstone does. Re-uploading the same file (same contentId) cleared
`deletedAt` locally but published the upsert with no token, so the tombstone
survived and the next pull (boot/periodic/book-open/visibility) re-applied
the soft-delete via softDeleteByContentId, making the font vanish. Logging
out stopped the pull, which is why it only reproduced while signed in.

addFont/addTexture now mint a reincarnation token when re-adding an existing
entry that is either soft-deleted or still-live with the same contentId (and
has no token yet), preserving any existing token. This mirrors the
dictionary's reincarnation handling (dictionaryService) and OPDS's token
style, fixing both the local re-import-after-delete case and the multi-device
stale-local race. The token is inert when there is no tombstone, so live
re-imports remain safe.

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Huang Xin
2026-06-02 21:56:38 +08:00
committed by GitHub
parent fe7fe25482
commit 274afb0677
4 changed files with 169 additions and 0 deletions
@@ -118,6 +118,66 @@ describe('customFontStore', () => {
const restored = useCustomFontStore.getState().fonts[0]!;
expect(restored.deletedAt).toBeUndefined();
});
// ── reincarnation on re-import (issue #4410) ───────────────────
// Deleting a font writes a server-side tombstone. Under CRDT
// remove-wins a plain re-upload can't revive it, so the next pull
// re-applies the delete and the font silently disappears while
// logged into cloud sync. Re-import must mint a reincarnation token.
test('re-import after a local delete mints + publishes a reincarnation token', () => {
useCustomFontStore.getState().addFont('/fonts/MyFont.ttf', { contentId: 'cid-1' });
useCustomFontStore.getState().removeFont(useCustomFontStore.getState().fonts[0]!.id);
mockPublishReplicaUpsert.mockClear();
const revived = useCustomFontStore.getState().addFont('/fonts/MyFont.ttf', {
contentId: 'cid-1',
});
expect(revived.deletedAt).toBeUndefined();
expect(revived.reincarnation).toBeTruthy();
expect(mockPublishReplicaUpsert).toHaveBeenCalledTimes(1);
const call = mockPublishReplicaUpsert.mock.calls[0]!;
expect(call[0]).toBe('font');
expect(call[2]).toBe('cid-1');
// 4th arg is the reincarnation token handed to publishReplicaUpsert.
expect(call[3]).toBe(revived.reincarnation);
});
test('re-import of a still-live font with the same contentId mints a token (stale-local race)', () => {
// Another device may have tombstoned the row while this device still
// has the font live. Minting on live re-import lets the upsert win
// remove-wins on every device's next pull. Mirrors dictionaryService.
useCustomFontStore.getState().addFont('/fonts/MyFont.ttf', { contentId: 'cid-1' });
expect(useCustomFontStore.getState().fonts[0]!.reincarnation).toBeUndefined();
const reimported = useCustomFontStore.getState().addFont('/fonts/MyFont.ttf', {
contentId: 'cid-1',
});
expect(reimported.deletedAt).toBeUndefined();
expect(reimported.reincarnation).toBeTruthy();
});
test('re-import preserves an existing reincarnation token instead of churning a new one', () => {
useCustomFontStore.getState().addFont('/fonts/MyFont.ttf', { contentId: 'cid-1' });
useCustomFontStore.getState().removeFont(useCustomFontStore.getState().fonts[0]!.id);
const firstToken = useCustomFontStore
.getState()
.addFont('/fonts/MyFont.ttf', { contentId: 'cid-1' }).reincarnation;
expect(firstToken).toBeTruthy();
const secondToken = useCustomFontStore
.getState()
.addFont('/fonts/MyFont.ttf', { contentId: 'cid-1' }).reincarnation;
expect(secondToken).toBe(firstToken);
});
test('brand-new import does not mint a reincarnation token', () => {
const fresh = useCustomFontStore.getState().addFont('/fonts/Brand-New.ttf', {
contentId: 'cid-new',
});
expect(fresh.reincarnation).toBeUndefined();
});
});
// ── removeFont ─────────────────────────────────────────────────
@@ -4,6 +4,7 @@ import { useSettingsStore } from '@/store/settingsStore';
import { CustomTexture } from '@/styles/textures';
import { SystemSettings } from '@/types/settings';
import { EnvConfigType } from '@/services/environment';
import { publishReplicaUpsert } from '@/services/sync/replicaPublish';
// Mock textures module - we need createCustomTexture, and the mount/unmount functions
vi.mock('@/styles/textures', async (importOriginal) => {
@@ -15,6 +16,15 @@ vi.mock('@/styles/textures', async (importOriginal) => {
};
});
// Replica-publish helpers fan out to the network — stub them so tests stay
// hermetic. We assert the reincarnation token reaches the upsert via a spy.
vi.mock('@/services/sync/replicaPublish', () => ({
publishReplicaUpsert: vi.fn(),
publishReplicaDelete: vi.fn(),
}));
const mockPublishReplicaUpsert = vi.mocked(publishReplicaUpsert);
function makeTexture(
overrides: Partial<CustomTexture> & { id: string; name: string },
): CustomTexture {
@@ -100,6 +110,65 @@ describe('customTextureStore', () => {
const updated = useCustomTextureStore.getState().textures[0]!;
expect(updated.deletedAt).toBeUndefined();
});
// ── reincarnation on re-import (same latent bug as issue #4410) ──
// A deleted texture writes a server-side tombstone; under CRDT
// remove-wins a plain re-import can't revive it, so the next pull
// re-applies the delete. Re-import must mint a reincarnation token.
test('re-import after a local delete mints + publishes a reincarnation token', () => {
useCustomTextureStore.getState().addTexture('/images/wood.png', { contentId: 'cid-1' });
useCustomTextureStore
.getState()
.removeTexture(useCustomTextureStore.getState().textures[0]!.id);
mockPublishReplicaUpsert.mockClear();
const revived = useCustomTextureStore.getState().addTexture('/images/wood.png', {
contentId: 'cid-1',
});
expect(revived.deletedAt).toBeUndefined();
expect(revived.reincarnation).toBeTruthy();
expect(mockPublishReplicaUpsert).toHaveBeenCalledTimes(1);
const call = mockPublishReplicaUpsert.mock.calls[0]!;
expect(call[0]).toBe('texture');
expect(call[2]).toBe('cid-1');
expect(call[3]).toBe(revived.reincarnation);
});
test('re-import of a still-live texture with the same contentId mints a token (stale-local race)', () => {
useCustomTextureStore.getState().addTexture('/images/wood.png', { contentId: 'cid-1' });
expect(useCustomTextureStore.getState().textures[0]!.reincarnation).toBeUndefined();
const reimported = useCustomTextureStore.getState().addTexture('/images/wood.png', {
contentId: 'cid-1',
});
expect(reimported.deletedAt).toBeUndefined();
expect(reimported.reincarnation).toBeTruthy();
});
test('re-import preserves an existing reincarnation token instead of churning a new one', () => {
useCustomTextureStore.getState().addTexture('/images/wood.png', { contentId: 'cid-1' });
useCustomTextureStore
.getState()
.removeTexture(useCustomTextureStore.getState().textures[0]!.id);
const firstToken = useCustomTextureStore
.getState()
.addTexture('/images/wood.png', { contentId: 'cid-1' }).reincarnation;
expect(firstToken).toBeTruthy();
const secondToken = useCustomTextureStore
.getState()
.addTexture('/images/wood.png', { contentId: 'cid-1' }).reincarnation;
expect(secondToken).toBe(firstToken);
});
test('brand-new import does not mint a reincarnation token', () => {
const fresh = useCustomTextureStore.getState().addTexture('/images/brand-new.png', {
contentId: 'cid-new',
});
expect(fresh.reincarnation).toBeUndefined();
});
});
// ── removeTexture ──────────────────────────────────────────────
@@ -87,11 +87,31 @@ export const useCustomFontStore = create<FontStoreState>((set, get) => ({
const font = createCustomFont(path, options);
const existingFont = get().fonts.find((f) => f.id === font.id);
if (existingFont) {
// Re-import of an existing font. Under CRDT remove-wins, a plain
// upsert can't revive a server-side tombstone — the next pull would
// re-apply the delete and the font silently disappears while logged
// into cloud sync (issue #4410). Mint a reincarnation token so the
// row surfaces as alive on every device's next pull. Covers both
// re-import after a local delete and re-import while the entry is
// still live but another device tombstoned the row (the token is
// inert when there's no tombstone). Preserve any existing token
// instead of churning a new one on each import. Mirrors
// dictionaryService's reincarnation logic.
const shouldMintReincarnation =
!!font.contentId &&
!existingFont.reincarnation &&
(!!existingFont.deletedAt || existingFont.contentId === font.contentId);
const reincarnation =
font.reincarnation ??
(shouldMintReincarnation
? Math.random().toString(36).slice(2)
: existingFont.reincarnation);
get().updateFont(font.id, {
...font,
path: font.path,
downloadedAt: Date.now(),
deletedAt: undefined,
reincarnation,
loaded: false,
blobUrl: undefined,
error: undefined,
@@ -92,11 +92,31 @@ export const useCustomTextureStore = create<TextureStoreState>((set, get) => ({
const existingTexture = get().textures.find((t) => t.id === texture.id);
if (existingTexture) {
// Re-import of an existing texture. Under CRDT remove-wins, a plain
// upsert can't revive a server-side tombstone — the next pull would
// re-apply the delete and the texture silently disappears while
// logged into cloud sync (same latent bug as issue #4410). Mint a
// reincarnation token so the row surfaces as alive on every device's
// next pull. Covers both re-import after a local delete and re-import
// while the entry is still live but another device tombstoned the row
// (the token is inert when there's no tombstone). Preserve any
// existing token instead of churning a new one on each import.
// Mirrors dictionaryService's reincarnation logic.
const shouldMintReincarnation =
!!texture.contentId &&
!existingTexture.reincarnation &&
(!!existingTexture.deletedAt || existingTexture.contentId === texture.contentId);
const reincarnation =
texture.reincarnation ??
(shouldMintReincarnation
? Math.random().toString(36).slice(2)
: existingTexture.reincarnation);
get().updateTexture(texture.id, {
...texture,
path: texture.path,
downloadedAt: Date.now(),
deletedAt: undefined,
reincarnation,
loaded: false,
blobUrl: undefined,
error: undefined,