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:
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user