Files
readest/apps/readest-app/src/services/sync/migrateLegacy.ts
T
Huang Xin 4625e47a6d 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>
2026-05-07 10:19:23 +02:00

126 lines
4.5 KiB
TypeScript

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);
}
};