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>
Three kinds (dictionary, font, texture) of replica sync now visibly
duplicate each other; this PR extracts the shared shape now that the
abstraction is well-validated, per the plan's "extract only after the
second kind validates" guidance.
- New `services/sync/adapters/_helpers.ts` — `unwrap` field-envelope,
`singleFileFilenameFromManifest`, `singleFileBinaryEnumerator`, and
a default `computeId` for kinds with `record.contentId`. Wired into
font + texture adapters (dictionary stays bespoke — multi-file
enumeration and a different identity recipe).
- `useReplicaPull.ts` collapses the three near-identical
`buildXPullDeps` (~80 lines each) into a single
`buildReplicaPullDeps<T>` factory plus three small `ReplicaPullConfig`
records (~12 lines each). Dispatch stays a typed `switch` to keep
the generic record type sound under contravariance.
- New `services/sync/migrateLegacy.ts` — `migrateLegacyReplicas<T>`
helper that owns the rehash-flat-path → `<bundleDir>/<filename>`
migration. `migrateLegacyFonts` and `migrateLegacyTextures` are now
thin per-kind configs, ~15 lines each (down from ~60).
Net: +143 / -291 across 5 files plus 2 new helpers. No behavior
change; 3933 tests still pass.
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Plug the texture replica adapter into the kind-agnostic primitives
shipped in #4077. Textures imported on one device download and
become available on every signed-in device, with the same shape as
the font sync stack (single-file binary, contentId from
partialMD5+size+filename, bundleDir layout, replica-publish on
import, full activation on auto-download).
Includes legacy flat-path migration so pre-existing textures sync
without re-import, ColorPanel import flow now publishes the row
and queues the binary upload, and createCustomTexture preserves
contentId/bundleDir/byteSize through addTexture (mirrors the
font-import fix). Server allowlist gains 'texture' with a
single-image Zod schema; useBackgroundTexture passes replica
metadata through addTexture so the boot-time "ensure selected
texture is in store" path doesn't silently un-publish a remote
record.
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>