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:
@@ -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);
|
||||
}
|
||||
};
|
||||
@@ -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') {
|
||||
|
||||
Reference in New Issue
Block a user