refactor(sync): extract shared adapter / pull-deps / legacy-migration primitives (#4081)

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>
This commit is contained in:
Huang Xin
2026-05-07 16:19:23 +08:00
committed by GitHub
parent 77a85cee09
commit 4625e47a6d
7 changed files with 323 additions and 291 deletions
+89 -87
View File
@@ -17,11 +17,16 @@ import { dictionaryAdapter } from '@/services/sync/adapters/dictionary';
import { fontAdapter } from '@/services/sync/adapters/font';
import { textureAdapter } from '@/services/sync/adapters/texture';
import { queueReplicaBinaryUpload } from '@/services/sync/replicaBinaryUpload';
import { replicaPullAndApply, type PullAndApplyDeps } from '@/services/sync/replicaPullAndApply';
import {
replicaPullAndApply,
type PullAndApplyDeps,
type ReplicaLocalRecord,
} from '@/services/sync/replicaPullAndApply';
import type { ReplicaAdapter } from '@/services/sync/replicaRegistry';
import { getAccessToken } from '@/utils/access';
import { uniqueId } from '@/utils/misc';
import type { EnvConfigType } from '@/services/environment';
import type { AppService } from '@/types/system';
import type { AppService, BaseDir } from '@/types/system';
import type { ReplicaSyncManager } from '@/services/sync/replicaSyncManager';
import type { ImportedDictionary } from '@/services/dictionaries/types';
import type { CustomFont } from '@/styles/fonts';
@@ -45,56 +50,85 @@ const REPLICA_PULL_DEFAULT_DELAY_MS = 10_000;
// which is wired once in EnvContext.
const pulledKinds = new Set<ReplicaKind>();
const buildDictionaryPullDeps = (
/**
* Per-kind config consumed by `buildReplicaPullDeps`. The factory fills
* in everything that is structurally identical across kinds (creating
* bundle dirs, queueing downloads, checking files exist, queueing
* upload of the local copy, gating on auth). Per-kind logic stays
* here: the adapter, base dir, find/hydrate/apply/soft-delete store
* accessors.
*/
interface ReplicaPullConfig<T extends ReplicaLocalRecord> {
kind: ReplicaKind;
baseDir: BaseDir;
adapter: ReplicaAdapter<T>;
findByContentId: (id: string) => T | undefined;
hydrateLocalStore?: (envConfig: EnvConfigType) => Promise<void>;
applyRemote: (record: T) => void;
softDeleteByContentId: (id: string) => void;
}
const buildReplicaPullDeps = <T extends ReplicaLocalRecord>(
manager: ReplicaSyncManager,
service: AppService,
envConfig: EnvConfigType,
): PullAndApplyDeps<ImportedDictionary> => ({
adapter: dictionaryAdapter,
config: ReplicaPullConfig<T>,
): PullAndApplyDeps<T> => ({
adapter: config.adapter,
// Boot path uses since=null so we always re-fetch and apply locally,
// ignoring any previously-advanced cursor. Periodic sync (visibility /
// online) goes through manager.pull(kind) which keeps using the cursor.
pull: () => manager.pull('dictionary', { since: null }),
// The page may mount before loadCustomDictionaries has hydrated the
// in-memory store, so the dedup helper falls back to settings.
findByContentId: (id: string) => findDictionaryByContentId(id),
// Pull-side relies on the in-memory dict store reflecting persisted
// state — without this, the auto-persist fired by applyRemoteDictionary
// would write back only the just-applied rows and clobber every
// persisted dict that hadn't been hydrated by an Annotator/Settings
// mount. Library-page refreshes were the visible victim.
hydrateLocalStore: () => useCustomDictionaryStore.getState().loadCustomDictionaries(envConfig),
applyRemote: (dict) => useCustomDictionaryStore.getState().applyRemoteDictionary(dict),
softDeleteByContentId: (id) => useCustomDictionaryStore.getState().softDeleteByContentId(id),
pull: () => manager.pull(config.kind, { since: null }),
findByContentId: config.findByContentId,
hydrateLocalStore: config.hydrateLocalStore
? () => config.hydrateLocalStore!(envConfig)
: undefined,
applyRemote: config.applyRemote,
softDeleteByContentId: config.softDeleteByContentId,
createBundleDir: async () => {
const id = uniqueId();
await service.createDir(id, 'Dictionaries', true);
await service.createDir(id, config.baseDir, true);
return id;
},
queueReplicaDownload: (contentId, displayTitle, files, _bundleDir, base) =>
transferManager.queueReplicaDownload('dictionary', contentId, displayTitle, files, base),
transferManager.queueReplicaDownload(config.kind, contentId, displayTitle, files, base),
filesExist: async (bundleDir, filenames) => {
for (const filename of filenames) {
const exists = await service.exists(`${bundleDir}/${filename}`, 'Dictionaries');
const exists = await service.exists(`${bundleDir}/${filename}`, config.baseDir);
if (!exists) return false;
}
return true;
},
queueLocalBinaryUpload: async (record) => {
await queueReplicaBinaryUpload('dictionary', record, service);
await queueReplicaBinaryUpload(config.kind, record, service);
},
isAuthenticated: async () => !!(await getAccessToken()),
});
const buildFontPullDeps = (
manager: ReplicaSyncManager,
service: AppService,
envConfig: EnvConfigType,
): PullAndApplyDeps<CustomFont> => ({
const dictionaryPullConfig: ReplicaPullConfig<ImportedDictionary> = {
kind: 'dictionary',
baseDir: 'Dictionaries',
adapter: dictionaryAdapter,
// Page may mount before loadCustomDictionaries has hydrated the
// in-memory store, so the dedup helper falls back to settings.
findByContentId: findDictionaryByContentId,
// Pull-side relies on the in-memory dict store reflecting persisted
// state — without this, the auto-persist fired by applyRemoteDictionary
// would write back only the just-applied rows and clobber every
// persisted dict that hadn't been hydrated by an Annotator/Settings
// mount. Library-page refreshes were the visible victim.
hydrateLocalStore: (envConfig) =>
useCustomDictionaryStore.getState().loadCustomDictionaries(envConfig),
applyRemote: (dict) => useCustomDictionaryStore.getState().applyRemoteDictionary(dict),
softDeleteByContentId: (id) => useCustomDictionaryStore.getState().softDeleteByContentId(id),
};
const fontPullConfig: ReplicaPullConfig<CustomFont> = {
kind: 'font',
baseDir: 'Fonts',
adapter: fontAdapter,
pull: () => manager.pull('font', { since: null }),
findByContentId: (id: string) => findFontByContentId(id),
hydrateLocalStore: async () => {
findByContentId: findFontByContentId,
hydrateLocalStore: async (envConfig) => {
await useCustomFontStore.getState().loadCustomFonts(envConfig);
// Rehash legacy flat-path fonts so the user doesn't have to
// re-import them by hand to get them onto other devices.
@@ -102,35 +136,14 @@ const buildFontPullDeps = (
},
applyRemote: (font) => useCustomFontStore.getState().applyRemoteFont(font),
softDeleteByContentId: (id) => useCustomFontStore.getState().softDeleteByContentId(id),
createBundleDir: async () => {
const id = uniqueId();
await service.createDir(id, 'Fonts', true);
return id;
},
queueReplicaDownload: (contentId, displayTitle, files, _bundleDir, base) =>
transferManager.queueReplicaDownload('font', contentId, displayTitle, files, base),
filesExist: async (bundleDir, filenames) => {
for (const filename of filenames) {
const exists = await service.exists(`${bundleDir}/${filename}`, 'Fonts');
if (!exists) return false;
}
return true;
},
queueLocalBinaryUpload: async (record) => {
await queueReplicaBinaryUpload('font', record, service);
},
isAuthenticated: async () => !!(await getAccessToken()),
});
};
const buildTexturePullDeps = (
manager: ReplicaSyncManager,
service: AppService,
envConfig: EnvConfigType,
): PullAndApplyDeps<CustomTexture> => ({
const texturePullConfig: ReplicaPullConfig<CustomTexture> = {
kind: 'texture',
baseDir: 'Images',
adapter: textureAdapter,
pull: () => manager.pull('texture', { since: null }),
findByContentId: (id: string) => findTextureByContentId(id),
hydrateLocalStore: async () => {
findByContentId: findTextureByContentId,
hydrateLocalStore: async (envConfig) => {
await useCustomTextureStore.getState().loadCustomTextures(envConfig);
// Rehash legacy flat-path textures so the user doesn't have to
// re-import them by hand to get them onto other devices.
@@ -138,25 +151,7 @@ const buildTexturePullDeps = (
},
applyRemote: (texture) => useCustomTextureStore.getState().applyRemoteTexture(texture),
softDeleteByContentId: (id) => useCustomTextureStore.getState().softDeleteByContentId(id),
createBundleDir: async () => {
const id = uniqueId();
await service.createDir(id, 'Images', true);
return id;
},
queueReplicaDownload: (contentId, displayTitle, files, _bundleDir, base) =>
transferManager.queueReplicaDownload('texture', contentId, displayTitle, files, base),
filesExist: async (bundleDir, filenames) => {
for (const filename of filenames) {
const exists = await service.exists(`${bundleDir}/${filename}`, 'Images');
if (!exists) return false;
}
return true;
},
queueLocalBinaryUpload: async (record) => {
await queueReplicaBinaryUpload('texture', record, service);
},
isAuthenticated: async () => !!(await getAccessToken()),
});
};
const runPullForKind = async (
kind: ReplicaKind,
@@ -165,19 +160,26 @@ const runPullForKind = async (
): Promise<void> => {
const ctx = getReplicaSync();
if (!ctx) return;
if (kind === 'dictionary') {
await replicaPullAndApply(buildDictionaryPullDeps(ctx.manager, service, envConfig));
return;
// Per-kind dispatch keeps the generic record type sound — collapsing
// the three configs into a Record<ReplicaKind, ReplicaPullConfig<...>>
// would force a contravariant cast that loses type safety.
switch (kind) {
case 'dictionary':
await replicaPullAndApply(
buildReplicaPullDeps(ctx.manager, service, envConfig, dictionaryPullConfig),
);
return;
case 'font':
await replicaPullAndApply(
buildReplicaPullDeps(ctx.manager, service, envConfig, fontPullConfig),
);
return;
case 'texture':
await replicaPullAndApply(
buildReplicaPullDeps(ctx.manager, service, envConfig, texturePullConfig),
);
return;
}
if (kind === 'font') {
await replicaPullAndApply(buildFontPullDeps(ctx.manager, service, envConfig));
return;
}
if (kind === 'texture') {
await replicaPullAndApply(buildTexturePullDeps(ctx.manager, service, envConfig));
return;
}
// Future: dispatch to other per-kind dep builders here.
};
/**
@@ -1,14 +1,17 @@
import { computeFontContentId } from '@/services/fontService';
import type { CustomFont } from '@/styles/fonts';
import type { ReplicaAdapter } from '@/services/sync/replicaRegistry';
import type { FieldEnvelope, FieldsObject, ReplicaRow } from '@/types/replica';
import type { FieldsObject, ReplicaRow } from '@/types/replica';
import {
defaultComputeId,
singleFileBinaryEnumerator,
singleFileFilenameFromManifest,
unwrap,
} from './helpers';
export const FONT_KIND = 'font';
export const FONT_SCHEMA_VERSION = 1;
const unwrap = (env: FieldEnvelope | undefined): unknown =>
env && typeof env === 'object' && 'v' in env ? (env as FieldEnvelope).v : undefined;
interface UnwrappedFontFields {
name?: string;
family?: string;
@@ -38,12 +41,6 @@ const unwrapFontFields = (fields: FieldsObject): UnwrappedFontFields => {
};
};
const filenameFromManifest = (row: ReplicaRow): string | null => {
// Font kind is single-file; the manifest carries exactly one entry.
const f = row.manifest_jsonb?.files[0];
return f?.filename ?? null;
};
export { computeFontContentId };
export const fontAdapter: ReplicaAdapter<CustomFont> = {
@@ -78,14 +75,12 @@ export const fontAdapter: ReplicaAdapter<CustomFont> = {
};
},
async computeId(f: CustomFont): Promise<string> {
return f.contentId ?? f.id;
},
computeId: defaultComputeId,
unpackRow(row: ReplicaRow, bundleDir: string): CustomFont | null {
const fields = unwrapFontFields(row.fields_jsonb);
if (!fields.name) return null;
const filename = filenameFromManifest(row);
const filename = singleFileFilenameFromManifest(row);
if (!filename) {
// No manifest yet — placeholder with empty path; the manifest
// commit on the publishing device will fill this in on the next
@@ -113,17 +108,6 @@ export const fontAdapter: ReplicaAdapter<CustomFont> = {
binary: {
localBaseDir: 'Fonts',
enumerateFiles: (font: CustomFont) => {
// Fonts are single-file. The font's `path` is the lfp, relative
// to the Fonts base dir.
const filename = font.path.split('/').pop() ?? font.path;
return [
{
logical: filename,
lfp: font.path,
byteSize: font.byteSize ?? 0,
},
];
},
enumerateFiles: singleFileBinaryEnumerator,
},
};
@@ -0,0 +1,53 @@
import type { FieldEnvelope, ReplicaRow } from '@/types/replica';
/**
* Unwrap a CRDT field envelope back to its raw value. Returns `undefined`
* when the envelope is missing or malformed (and for cipher envelopes,
* which only the per-adapter encrypt/decrypt path knows how to handle).
*
* Shared across adapters that read scalar fields out of `fields_jsonb` —
* the dictionary, font, and texture adapters all unwrap the same shape.
*/
export const unwrap = (env: FieldEnvelope | undefined): unknown =>
env && typeof env === 'object' && 'v' in env ? (env as FieldEnvelope).v : undefined;
/**
* Pull the single manifest filename for a kind whose binary capability
* always lists exactly one file (font, texture). Returns `null` when the
* manifest hasn't been committed yet — the orchestrator skips such rows
* and re-pulls them on the next cycle once the publishing device's
* upload completes.
*/
export const singleFileFilenameFromManifest = (row: ReplicaRow): string | null => {
const f = row.manifest_jsonb?.files[0];
return f?.filename ?? null;
};
/**
* `binary.enumerateFiles` for any single-file kind whose record carries
* a relative `path` (`<bundleDir>/<filename>`) and an optional `byteSize`.
* Returns one entry; legacy flat-path records (no slash in `path`) work
* without modification — the filename falls back to the path itself.
*/
export const singleFileBinaryEnumerator = <T extends { path: string; byteSize?: number }>(
record: T,
): { logical: string; lfp: string; byteSize: number }[] => {
const filename = record.path.split('/').pop() ?? record.path;
return [
{
logical: filename,
lfp: record.path,
byteSize: record.byteSize ?? 0,
},
];
};
/**
* Default `computeId` for kinds that compute their cross-device identity
* via a content hash stored in `record.contentId` (set at import time)
* and fall back to the local `record.id` for legacy entries that
* predate replica sync.
*/
export const defaultComputeId = async <T extends { contentId?: string; id: string }>(
record: T,
): Promise<string> => record.contentId ?? record.id;
@@ -1,14 +1,17 @@
import { computeTextureContentId } from '@/services/imageService';
import type { CustomTexture } from '@/styles/textures';
import type { ReplicaAdapter } from '@/services/sync/replicaRegistry';
import type { FieldEnvelope, FieldsObject, ReplicaRow } from '@/types/replica';
import type { FieldsObject, ReplicaRow } from '@/types/replica';
import {
defaultComputeId,
singleFileBinaryEnumerator,
singleFileFilenameFromManifest,
unwrap,
} from './helpers';
export const TEXTURE_KIND = 'texture';
export const TEXTURE_SCHEMA_VERSION = 1;
const unwrap = (env: FieldEnvelope | undefined): unknown =>
env && typeof env === 'object' && 'v' in env ? (env as FieldEnvelope).v : undefined;
interface UnwrappedTextureFields {
name?: string;
byteSize?: number;
@@ -26,12 +29,6 @@ const unwrapTextureFields = (fields: FieldsObject): UnwrappedTextureFields => {
};
};
const filenameFromManifest = (row: ReplicaRow): string | null => {
// Texture kind is single-file; the manifest carries exactly one entry.
const f = row.manifest_jsonb?.files[0];
return f?.filename ?? null;
};
export { computeTextureContentId };
export const textureAdapter: ReplicaAdapter<CustomTexture> = {
@@ -58,14 +55,12 @@ export const textureAdapter: ReplicaAdapter<CustomTexture> = {
};
},
async computeId(t: CustomTexture): Promise<string> {
return t.contentId ?? t.id;
},
computeId: defaultComputeId,
unpackRow(row: ReplicaRow, bundleDir: string): CustomTexture | null {
const fields = unwrapTextureFields(row.fields_jsonb);
if (!fields.name) return null;
const filename = filenameFromManifest(row);
const filename = singleFileFilenameFromManifest(row);
if (!filename) {
// No manifest yet — placeholder with empty path; the manifest
// commit on the publishing device will fill this in on the next
@@ -89,17 +84,6 @@ export const textureAdapter: ReplicaAdapter<CustomTexture> = {
binary: {
localBaseDir: 'Images',
enumerateFiles: (texture: CustomTexture) => {
// Textures are single-file. The texture's `path` is the lfp,
// relative to the Images base dir.
const filename = texture.path.split('/').pop() ?? texture.path;
return [
{
logical: filename,
lfp: texture.path,
byteSize: texture.byteSize ?? 0,
},
];
},
enumerateFiles: singleFileBinaryEnumerator,
},
};
@@ -0,0 +1,125 @@
import { partialMD5 } from '@/utils/md5';
import { uniqueId } from '@/utils/misc';
import { queueReplicaBinaryUpload } from '@/services/sync/replicaBinaryUpload';
import type { EnvConfigType } from '@/services/environment';
import type { BaseDir } from '@/types/system';
/**
* Shared shape for legacy flat-path records that predate replica sync:
* a flat `<filename>` path under the kind's base dir, no `contentId`
* or `bundleDir`. The migration rehashes the bytes into a per-record
* `<bundleDir>/<filename>` layout so the kind starts syncing without
* forcing the user to re-import.
*/
interface LegacyReplicaRecord {
id: string;
name: string;
path: string;
contentId?: string;
bundleDir?: string;
byteSize?: number;
reincarnation?: string;
blobUrl?: string;
loaded?: boolean;
error?: string;
}
export interface MigrateLegacyReplicasDeps<T extends LegacyReplicaRecord> {
/** Replica kind name — passed to `queueReplicaBinaryUpload`. */
kind: string;
/** App-service base dir for the kind (`'Fonts'`, `'Images'`, ...). */
baseDir: BaseDir;
/**
* Snapshot of legacy candidates: live records with no `contentId` or
* `bundleDir` and a flat `path`. The caller filters in-store; this
* helper is purely the on-disk + publish side of the migration.
*/
getCandidates: () => T[];
/** `md5(partialMd5 ‖ byteSize ‖ filename)` — same recipe per kind. */
computeContentId: (partialMd5: string, byteSize: number, filename: string) => string;
/** Patch the in-memory record with the migrated fields. */
updateRecord: (id: string, next: T) => void;
/** Persist the kind's settings entry post-migration. */
saveStore: (envConfig: EnvConfigType) => Promise<void>;
/** Publish the now-syncable record to the replica row. */
publishUpsert: (record: T) => void;
}
/**
* One-time migration: rehash legacy flat-path records (imported before
* replica sync shipped) into the per-bundle layout so they sync
* across devices without forcing the user to re-import.
*
* For each candidate:
* 1. Read bytes from `<baseDir>/<filename>`.
* 2. Compute partialMD5 + size + filename → contentId.
* 3. Mint `bundleDir = uniqueId()`; copy the file to
* `<baseDir>/<bundleDir>/<filename>` and remove the flat-path one.
* 4. Patch the in-memory record (contentId, bundleDir, byteSize,
* path), persist via `saveStore`, then publish through
* `publishUpsert` and queue the binary upload.
*
* Idempotent: a record that already carries `contentId` is filtered
* out upstream by `getCandidates`. If the file is missing on disk,
* the migration leaves the record untouched. Per-record failures are
* logged and don't block the rest.
*/
export const migrateLegacyReplicas = async <T extends LegacyReplicaRecord>(
envConfig: EnvConfigType,
deps: MigrateLegacyReplicasDeps<T>,
): Promise<void> => {
const candidates = deps.getCandidates();
if (candidates.length === 0) return;
const appService = await envConfig.getAppService();
const migrated: T[] = [];
for (const legacy of candidates) {
try {
const exists = await appService.exists(legacy.path, deps.baseDir);
if (!exists) continue;
const file = await appService.openFile(legacy.path, deps.baseDir);
const bytes = await file.arrayBuffer();
const partialMd5 = await partialMD5(file);
const byteSize = bytes.byteLength;
const filename = legacy.path;
const contentId = deps.computeContentId(partialMd5, byteSize, filename);
const bundleDir = uniqueId();
const newPath = `${bundleDir}/${filename}`;
await appService.createDir(bundleDir, deps.baseDir, true);
await appService.copyFile(legacy.path, deps.baseDir, newPath, deps.baseDir);
await appService.deleteFile(legacy.path, deps.baseDir);
const next: T = {
...legacy,
contentId,
bundleDir,
byteSize,
path: newPath,
// Force a re-load on next render so the blob URL points at the
// new on-disk path.
blobUrl: undefined,
loaded: false,
error: undefined,
};
deps.updateRecord(legacy.id, next);
migrated.push(next);
} catch (err) {
console.warn(`migrateLegacyReplicas[${deps.kind}]: failed for`, legacy.path, err);
}
}
if (migrated.length === 0) return;
try {
await deps.saveStore(envConfig);
} catch (err) {
console.warn(`migrateLegacyReplicas[${deps.kind}]: save failed`, err);
}
for (const record of migrated) {
deps.publishUpsert(record);
void queueReplicaBinaryUpload(deps.kind, record, appService);
}
};
+17 -77
View File
@@ -12,9 +12,7 @@ import { getReplicaPersistEnv } from '@/services/sync/replicaPersist';
import { publishReplicaDelete, publishReplicaUpsert } from '@/services/sync/replicaPublish';
import { FONT_KIND } from '@/services/sync/adapters/font';
import { computeFontContentId } from '@/services/fontService';
import { queueReplicaBinaryUpload } from '@/services/sync/replicaBinaryUpload';
import { partialMD5 } from '@/utils/md5';
import { uniqueId } from '@/utils/misc';
import { migrateLegacyReplicas } from '@/services/sync/migrateLegacy';
const publishFontUpsert = (font: CustomFont): void => {
if (!font.contentId) return;
@@ -420,81 +418,23 @@ export const findFontByContentId = (contentId: string): CustomFont | undefined =
/**
* One-time migration: rehash legacy flat-path fonts (imported before
* replica sync shipped) into the per-bundle layout so they sync
* across devices without forcing the user to re-import.
*
* For each live font with no `contentId`:
* 1. Read bytes from `Fonts/<filename>`.
* 2. Compute partialMD5 + size → contentId.
* 3. Mint `bundleDir = uniqueId()`; copy the file to
* `Fonts/<bundleDir>/<filename>` and remove the flat-path one.
* 4. Patch the in-memory record (contentId, bundleDir, byteSize,
* path), persist via saveCustomFonts, then publish through
* publishReplicaUpsert.
*
* Idempotent: a font that already carries `contentId` is skipped.
* If the file is missing on disk, the migration leaves the record
* untouched (loadCustomFonts will mark it `unavailable`). Per-font
* failures are logged and don't block the rest.
* across devices without forcing the user to re-import. Idempotent;
* skips fonts that already carry `contentId`. Implementation lives in
* `migrateLegacyReplicas` — shared with custom textures.
*/
export const migrateLegacyFonts = async (envConfig: EnvConfigType): Promise<void> => {
const candidates = useCustomFontStore
.getState()
.fonts.filter((f) => !f.contentId && !f.bundleDir && !f.deletedAt && !f.path.includes('/'));
if (candidates.length === 0) return;
const appService = await envConfig.getAppService();
const migrated: CustomFont[] = [];
for (const legacy of candidates) {
try {
const exists = await appService.exists(legacy.path, 'Fonts');
if (!exists) continue;
const file = await appService.openFile(legacy.path, 'Fonts');
const bytes = await file.arrayBuffer();
const partialMd5 = await partialMD5(file);
const byteSize = bytes.byteLength;
const filename = legacy.path;
const contentId = computeFontContentId(partialMd5, byteSize, filename);
const bundleDir = uniqueId();
const newPath = `${bundleDir}/${filename}`;
await appService.createDir(bundleDir, 'Fonts', true);
await appService.copyFile(legacy.path, 'Fonts', newPath, 'Fonts');
await appService.deleteFile(legacy.path, 'Fonts');
const next: CustomFont = {
...legacy,
contentId,
bundleDir,
byteSize,
path: newPath,
// Force a re-load on next render so the blob URL points at the
// new on-disk path. Loaders observe `loaded=false` + missing
// blobUrl and refresh.
blobUrl: undefined,
loaded: false,
error: undefined,
};
useCustomFontStore.getState().updateFont(legacy.id, next);
migrated.push(next);
} catch (err) {
console.warn('migrateLegacyFonts: failed for', legacy.path, err);
}
}
if (migrated.length === 0) return;
try {
await useCustomFontStore.getState().saveCustomFonts(envConfig);
} catch (err) {
console.warn('migrateLegacyFonts: save failed', err);
}
for (const font of migrated) {
publishFontUpsert(font);
void queueReplicaBinaryUpload('font', font, appService);
}
};
export const migrateLegacyFonts = (envConfig: EnvConfigType): Promise<void> =>
migrateLegacyReplicas<CustomFont>(envConfig, {
kind: FONT_KIND,
baseDir: 'Fonts',
getCandidates: () =>
useCustomFontStore
.getState()
.fonts.filter((f) => !f.contentId && !f.bundleDir && !f.deletedAt && !f.path.includes('/')),
computeContentId: computeFontContentId,
updateRecord: (id, next) => useCustomFontStore.getState().updateFont(id, next),
saveStore: (env) => useCustomFontStore.getState().saveCustomFonts(env),
publishUpsert: publishFontUpsert,
});
if (typeof window !== 'undefined') {
window.addEventListener('beforeunload', () => {
@@ -12,9 +12,7 @@ import { getReplicaPersistEnv } from '@/services/sync/replicaPersist';
import { publishReplicaDelete, publishReplicaUpsert } from '@/services/sync/replicaPublish';
import { TEXTURE_KIND } from '@/services/sync/adapters/texture';
import { computeTextureContentId } from '@/services/imageService';
import { queueReplicaBinaryUpload } from '@/services/sync/replicaBinaryUpload';
import { partialMD5 } from '@/utils/md5';
import { uniqueId } from '@/utils/misc';
import { migrateLegacyReplicas } from '@/services/sync/migrateLegacy';
const publishTextureUpsert = (texture: CustomTexture): void => {
if (!texture.contentId) return;
@@ -438,79 +436,25 @@ export const findTextureByContentId = (contentId: string): CustomTexture | undef
/**
* One-time migration: rehash legacy flat-path textures (imported before
* replica sync shipped) into the per-bundle layout so they sync
* across devices without forcing the user to re-import.
*
* For each live texture with no `contentId`:
* 1. Read bytes from `Images/<filename>`.
* 2. Compute partialMD5 + size → contentId.
* 3. Mint `bundleDir = uniqueId()`; copy the file to
* `Images/<bundleDir>/<filename>` and remove the flat-path one.
* 4. Patch the in-memory record (contentId, bundleDir, byteSize,
* path), persist via saveCustomTextures, then publish through
* publishReplicaUpsert.
*
* Idempotent: a texture that already carries `contentId` is skipped.
* If the file is missing on disk, the migration leaves the record
* untouched. Per-texture failures are logged and don't block the rest.
* across devices without forcing the user to re-import. Idempotent;
* skips textures that already carry `contentId`. Implementation lives
* in `migrateLegacyReplicas` — shared with custom fonts.
*/
export const migrateLegacyTextures = async (envConfig: EnvConfigType): Promise<void> => {
const candidates = useCustomTextureStore
.getState()
.textures.filter((t) => !t.contentId && !t.bundleDir && !t.deletedAt && !t.path.includes('/'));
if (candidates.length === 0) return;
const appService = await envConfig.getAppService();
const migrated: CustomTexture[] = [];
for (const legacy of candidates) {
try {
const exists = await appService.exists(legacy.path, 'Images');
if (!exists) continue;
const file = await appService.openFile(legacy.path, 'Images');
const bytes = await file.arrayBuffer();
const partialMd5 = await partialMD5(file);
const byteSize = bytes.byteLength;
const filename = legacy.path;
const contentId = computeTextureContentId(partialMd5, byteSize, filename);
const bundleDir = uniqueId();
const newPath = `${bundleDir}/${filename}`;
await appService.createDir(bundleDir, 'Images', true);
await appService.copyFile(legacy.path, 'Images', newPath, 'Images');
await appService.deleteFile(legacy.path, 'Images');
const next: CustomTexture = {
...legacy,
contentId,
bundleDir,
byteSize,
path: newPath,
// Force a re-load on next render so the blob URL points at the
// new on-disk path.
blobUrl: undefined,
loaded: false,
error: undefined,
};
useCustomTextureStore.getState().updateTexture(legacy.id, next);
migrated.push(next);
} catch (err) {
console.warn('migrateLegacyTextures: failed for', legacy.path, err);
}
}
if (migrated.length === 0) return;
try {
await useCustomTextureStore.getState().saveCustomTextures(envConfig);
} catch (err) {
console.warn('migrateLegacyTextures: save failed', err);
}
for (const texture of migrated) {
publishTextureUpsert(texture);
void queueReplicaBinaryUpload('texture', texture, appService);
}
};
export const migrateLegacyTextures = (envConfig: EnvConfigType): Promise<void> =>
migrateLegacyReplicas<CustomTexture>(envConfig, {
kind: TEXTURE_KIND,
baseDir: 'Images',
getCandidates: () =>
useCustomTextureStore
.getState()
.textures.filter(
(t) => !t.contentId && !t.bundleDir && !t.deletedAt && !t.path.includes('/'),
),
computeContentId: computeTextureContentId,
updateRecord: (id, next) => useCustomTextureStore.getState().updateTexture(id, next),
saveStore: (env) => useCustomTextureStore.getState().saveCustomTextures(env),
publishUpsert: publishTextureUpsert,
});
// Cleanup blob URLs before page unload
if (typeof window !== 'undefined') {