super-productivity/src/app/imex/sync/sync-wrapper.service.ts

744 lines
28 KiB
TypeScript

import { inject, Injectable } from '@angular/core';
import { BehaviorSubject, firstValueFrom, Observable, of } from 'rxjs';
import { GlobalConfigService } from '../../features/config/global-config.service';
import {
distinctUntilChanged,
filter,
first,
map,
shareReplay,
switchMap,
take,
timeout,
} from 'rxjs/operators';
import { toObservable } from '@angular/core/rxjs-interop';
import {
SyncAlreadyInProgressError,
LocalDataConflictError,
} from '../../op-log/core/errors/sync-errors';
import { SyncConfig } from '../../features/config/global-config.model';
import { TranslateService } from '@ngx-translate/core';
import { MatDialog, MatDialogRef } from '@angular/material/dialog';
import { SnackService } from '../../core/snack/snack.service';
import {
AuthFailSPError,
CanNotMigrateMajorDownError,
ConflictData,
ConflictReason,
DecryptError,
DecryptNoPasswordError,
LockPresentError,
NoRemoteModelFile,
PotentialCorsError,
RevMismatchForModelError,
SyncInvalidTimeValuesError,
SyncProviderId,
SyncStatus,
toSyncProviderId,
} from '../../op-log/sync-exports';
import { SyncProviderManager } from '../../op-log/sync-providers/provider-manager.service';
import { LegacyPfDbService } from '../../core/persistence/legacy-pf-db.service';
import { T } from '../../t.const';
import { getSyncErrorStr } from './get-sync-error-str';
import { DialogGetAndEnterAuthCodeComponent } from './dialog-get-and-enter-auth-code/dialog-get-and-enter-auth-code.component';
import { DialogConflictResolutionResult } from './sync.model';
import { DialogSyncConflictComponent } from './dialog-sync-conflict/dialog-sync-conflict.component';
import { ReminderService } from '../../features/reminder/reminder.service';
import { DataInitService } from '../../core/data-init/data-init.service';
import { DialogSyncInitialCfgComponent } from './dialog-sync-initial-cfg/dialog-sync-initial-cfg.component';
import { DialogIncompleteSyncComponent } from './dialog-incomplete-sync/dialog-incomplete-sync.component';
import { DialogHandleDecryptErrorComponent } from './dialog-handle-decrypt-error/dialog-handle-decrypt-error.component';
import { DialogIncoherentTimestampsErrorComponent } from './dialog-incoherent-timestamps-error/dialog-incoherent-timestamps-error.component';
import { SyncLog } from '../../core/log';
import { promiseTimeout } from '../../util/promise-timeout';
import { devError } from '../../util/dev-error';
import { UserInputWaitStateService } from './user-input-wait-state.service';
import { LegacySyncProvider } from './legacy-sync-provider.model';
import { SYNC_WAIT_TIMEOUT_MS, SYNC_REINIT_DELAY_MS } from './sync.const';
import { SuperSyncStatusService } from '../../op-log/sync/super-sync-status.service';
import { IS_ELECTRON } from '../../app.constants';
import { OperationLogStoreService } from '../../op-log/persistence/operation-log-store.service';
import { OperationLogSyncService } from '../../op-log/sync/operation-log-sync.service';
import { WrappedProviderService } from '../../op-log/sync-providers/wrapped-provider.service';
@Injectable({
providedIn: 'root',
})
export class SyncWrapperService {
private _providerManager = inject(SyncProviderManager);
private _legacyPfDb = inject(LegacyPfDbService);
private _globalConfigService = inject(GlobalConfigService);
private _translateService = inject(TranslateService);
private _snackService = inject(SnackService);
private _matDialog = inject(MatDialog);
private _dataInitService = inject(DataInitService);
private _reminderService = inject(ReminderService);
private _userInputWaitState = inject(UserInputWaitStateService);
private _superSyncStatusService = inject(SuperSyncStatusService);
private _opLogStore = inject(OperationLogStoreService);
private _opLogSyncService = inject(OperationLogSyncService);
private _wrappedProvider = inject(WrappedProviderService);
syncState$ = this._providerManager.syncStatus$;
syncCfg$: Observable<SyncConfig> = this._globalConfigService.cfg$.pipe(
map((cfg) => cfg?.sync),
);
syncProviderId$: Observable<SyncProviderId | null> = this.syncCfg$.pipe(
map((cfg) => toSyncProviderId(cfg.syncProvider)),
);
// SuperSync always uses 1 minute interval; other providers use configured value
// Return 0 when manual sync only is enabled to disable automatic triggers
syncInterval$: Observable<number> = this.syncCfg$.pipe(
map((cfg) => {
if (cfg.isManualSyncOnly) return 0;
return cfg.syncProvider === LegacySyncProvider.SuperSync ? 60000 : cfg.syncInterval;
}),
);
isEnabledAndReady$: Observable<boolean> = this._providerManager.isProviderReady$;
// NOTE we don't use this._pfapiService.isSyncInProgress$ since it does not include handling and re-init view model
private _isSyncInProgress$ = new BehaviorSubject(false);
isSyncInProgress$ = this._isSyncInProgress$.asObservable();
/**
* Observable for UI: true when all local changes have been uploaded.
* Used for all sync providers to show the single checkmark indicator.
*/
hasNoPendingOps$: Observable<boolean> = toObservable(
this._superSyncStatusService.hasNoPendingOps,
).pipe(distinctUntilChanged(), shareReplay(1));
/**
* Observable for UI: true when sync is confirmed fully in sync
* (no pending ops AND remote recently checked within 1 minute).
* Used for all sync providers to show the double checkmark indicator.
*/
superSyncIsConfirmedInSync$: Observable<boolean> = toObservable(
this._superSyncStatusService.isConfirmedInSync,
).pipe(distinctUntilChanged(), shareReplay(1));
isSyncInProgressSync(): boolean {
return this._isSyncInProgress$.getValue();
}
// Expose shared user input wait state for other services (e.g., SyncTriggerService)
isWaitingForUserInput$ = this._userInputWaitState.isWaitingForUserInput$;
afterCurrentSyncDoneOrSyncDisabled$: Observable<unknown> = this.isEnabledAndReady$.pipe(
switchMap((isEnabled) =>
isEnabled
? this._isSyncInProgress$.pipe(
filter((isInProgress) => !isInProgress),
timeout({
each: SYNC_WAIT_TIMEOUT_MS,
with: () =>
// If waiting for user input, don't error - just wait indefinitely
this._userInputWaitState.isWaitingForUserInput$.pipe(
switchMap((isWaiting) => {
if (isWaiting) {
// Continue waiting for sync to complete (no timeout)
return this._isSyncInProgress$.pipe(
filter((isInProgress) => !isInProgress),
);
}
devError('Sync wait timeout exceeded');
return of(undefined);
}),
),
}),
)
: of(undefined),
),
first(),
);
async sync(): Promise<SyncStatus | 'HANDLED_ERROR'> {
// Race condition fix: Check-and-set atomically before starting sync
if (this._isSyncInProgress$.getValue()) {
SyncLog.log('Sync already in progress, skipping concurrent sync attempt');
return 'HANDLED_ERROR';
}
this._isSyncInProgress$.next(true);
// Set SYNCING status so ImmediateUploadService knows not to interfere
this._providerManager.setSyncStatus('SYNCING');
return this._sync().finally(() => {
this._isSyncInProgress$.next(false);
// Safeguard: if _sync() threw or completed without setting a final status,
// reset from SYNCING to UNKNOWN_OR_CHANGED to avoid getting stuck in SYNCING state
if (this._providerManager.isSyncInProgress) {
this._providerManager.setSyncStatus('UNKNOWN_OR_CHANGED');
}
});
}
private async _sync(): Promise<SyncStatus | 'HANDLED_ERROR'> {
const providerId = await this.syncProviderId$.pipe(take(1)).toPromise();
if (!providerId) {
throw new Error('No Sync Provider for sync()');
}
try {
// PERF: For legacy sync providers (WebDAV, Dropbox, LocalFile), sync the vector clock
// from SUP_OPS to pf.META_MODEL before sync. This bridges the gap between the new
// atomic write system (vector clock in SUP_OPS) and legacy sync which reads from pf.
// SuperSync uses operation log directly, so it doesn't need this bridge.
if (providerId !== SyncProviderId.SuperSync) {
await this._syncVectorClockToPfapi();
}
// Get the sync-capable version of the provider
// - SuperSync: returned as-is (already implements OperationSyncCapable)
// - File-based (Dropbox, WebDAV, LocalFile): wrapped with FileBasedSyncAdapterService
const rawProvider = this._providerManager.getActiveProvider();
const syncCapableProvider =
await this._wrappedProvider.getOperationSyncCapable(rawProvider);
if (!syncCapableProvider) {
SyncLog.warn('SyncWrapperService: Provider does not support operation sync');
return SyncStatus.InSync;
}
// Perform actual sync: download first, then upload
SyncLog.log('SyncWrapperService: Starting op-log sync...');
// 1. Download remote ops first (important for fresh clients to receive data)
const downloadResult =
await this._opLogSyncService.downloadRemoteOps(syncCapableProvider);
SyncLog.log(
`SyncWrapperService: Download complete. newOps=${downloadResult.newOpsCount}, migration=${downloadResult.serverMigrationHandled}`,
);
// If user cancelled the sync import conflict dialog, skip upload entirely.
// This keeps the local state unchanged and doesn't push it to the server.
if (downloadResult.cancelled) {
SyncLog.log('SyncWrapperService: Sync cancelled by user. Skipping upload phase.');
this._providerManager.setSyncStatus('UNKNOWN_OR_CHANGED');
return SyncStatus.NotConfigured;
}
// 2. Upload pending local ops
const uploadResult =
await this._opLogSyncService.uploadPendingOps(syncCapableProvider);
if (uploadResult) {
SyncLog.log(
`SyncWrapperService: Upload complete. uploaded=${uploadResult.uploadedCount}, piggybacked=${uploadResult.piggybackedOps.length}`,
);
}
// 3. If LWW created local-win ops, upload them
const totalLocalWinOps =
(downloadResult.localWinOpsCreated ?? 0) +
(uploadResult?.localWinOpsCreated ?? 0);
if (totalLocalWinOps > 0) {
SyncLog.log(
`SyncWrapperService: Re-uploading ${totalLocalWinOps} local-win op(s) from LWW...`,
);
await this._opLogSyncService.uploadPendingOps(syncCapableProvider);
}
// 4. Check for rejected full-state ops - this is a critical failure that should
// NOT be reported as "in sync" to the user
if (uploadResult?.rejectedCount && uploadResult.rejectedCount > 0) {
const hasPayloadError = uploadResult.rejectedOps?.some(
(r) =>
r.error?.includes('Payload too complex') ||
r.error?.includes('Payload too large'),
);
if (hasPayloadError) {
SyncLog.err(
'SyncWrapperService: Upload rejected - payload too large/complex',
uploadResult.rejectedOps,
);
this._providerManager.setSyncStatus('ERROR');
// Use alert for maximum visibility - this is a critical error
alert(this._translateService.instant(T.F.SYNC.S.ERROR_PAYLOAD_TOO_LARGE));
return 'HANDLED_ERROR';
}
// Other rejections - still shouldn't claim success
SyncLog.err(
'SyncWrapperService: Upload had rejected operations, not marking as IN_SYNC',
uploadResult.rejectedOps,
);
this._providerManager.setSyncStatus('ERROR');
return 'HANDLED_ERROR';
}
// Mark as in-sync for all providers after successful sync
// This indicates the sync operation completed successfully
this._providerManager.setSyncStatus('IN_SYNC');
SyncLog.log('SyncWrapperService: Sync complete, status=IN_SYNC');
return SyncStatus.InSync;
} catch (error) {
SyncLog.err(error);
if (error instanceof PotentialCorsError) {
this._snackService.open({
msg: T.F.SYNC.S.ERROR_CORS,
type: 'ERROR',
// a bit longer since it is a long message
config: { duration: 12000 },
});
return 'HANDLED_ERROR';
} else if (error instanceof AuthFailSPError) {
this._snackService.open({
msg: T.F.SYNC.S.INCOMPLETE_CFG,
type: 'ERROR',
actionFn: async () => this._matDialog.open(DialogSyncInitialCfgComponent),
actionStr: T.F.SYNC.S.BTN_CONFIGURE,
});
return 'HANDLED_ERROR';
} else if (error instanceof SyncInvalidTimeValuesError) {
// Handle async dialog result properly to avoid silent error swallowing
this._handleIncoherentTimestampsDialog();
return 'HANDLED_ERROR';
} else if (
error instanceof RevMismatchForModelError ||
error instanceof NoRemoteModelFile
) {
SyncLog.log(error, Object.keys(error));
// Extract modelId safely with proper type validation
const modelId = this._extractModelIdFromError(error);
// Handle async dialog result properly to avoid silent error swallowing
this._handleIncompleteSyncDialog(modelId);
return 'HANDLED_ERROR';
} else if (error instanceof LockPresentError) {
this._snackService.open({
// TODO translate
msg: T.F.SYNC.S.ERROR_DATA_IS_CURRENTLY_WRITTEN,
type: 'ERROR',
actionFn: async () => this.forceUpload(),
actionStr: T.F.SYNC.S.BTN_FORCE_OVERWRITE,
});
return 'HANDLED_ERROR';
} else if (
error instanceof DecryptNoPasswordError ||
error instanceof DecryptError
) {
this._handleDecryptionError();
return 'HANDLED_ERROR';
} else if (error instanceof CanNotMigrateMajorDownError) {
// Log warning instead of alert - user can't do anything about version mismatch during import
SyncLog.warn(
'Remote model version newer than local - app update may be required',
);
return 'HANDLED_ERROR';
} else if (error instanceof SyncAlreadyInProgressError) {
// Silently ignore concurrent sync attempts (using proper error class)
SyncLog.log('Sync already in progress, skipping concurrent sync attempt');
return 'HANDLED_ERROR';
} else if (error instanceof LocalDataConflictError) {
// File-based sync: Local data exists and remote snapshot would overwrite it
// Show conflict dialog to let user choose between local and remote data
return this._handleLocalDataConflict(error);
} else if (this._isPermissionError(error)) {
this._snackService.open({
msg: this._getPermissionErrorMessage(),
type: 'ERROR',
config: { duration: 12000 },
});
return 'HANDLED_ERROR';
} else {
const errStr = getSyncErrorStr(error);
this._snackService.open({
// msg: T.F.SYNC.S.UNKNOWN_ERROR,
msg: errStr,
type: 'ERROR',
translateParams: {
err: errStr,
},
});
return 'HANDLED_ERROR';
}
}
}
async forceUpload(): Promise<void> {
if (!this._c(this._translateService.instant(T.F.SYNC.C.FORCE_UPLOAD))) {
return;
}
SyncLog.log('SyncWrapperService: forceUpload called - uploading local state');
try {
const rawProvider = this._providerManager.getActiveProvider();
const syncCapableProvider =
await this._wrappedProvider.getOperationSyncCapable(rawProvider);
if (!syncCapableProvider) {
SyncLog.warn('SyncWrapperService: Cannot force upload - provider not available');
return;
}
await this._opLogSyncService.forceUploadLocalState(syncCapableProvider);
this._providerManager.setSyncStatus('IN_SYNC');
SyncLog.log('SyncWrapperService: Force upload complete');
} catch (error) {
SyncLog.err('SyncWrapperService: Force upload failed:', error);
const errStr = getSyncErrorStr(error);
this._snackService.open({
msg: errStr,
type: 'ERROR',
});
}
}
async configuredAuthForSyncProviderIfNecessary(
providerId: SyncProviderId,
): Promise<{ wasConfigured: boolean }> {
const provider = this._providerManager.getProviderById(providerId);
if (!provider) {
return { wasConfigured: false };
}
if (!provider.getAuthHelper) {
return { wasConfigured: false };
}
if (await provider.isReady()) {
SyncLog.warn('Provider already configured');
return { wasConfigured: false };
}
try {
const { authUrl, codeVerifier, verifyCodeChallenge } =
await provider.getAuthHelper();
if (authUrl && codeVerifier && verifyCodeChallenge) {
const authCode = await this._matDialog
.open(DialogGetAndEnterAuthCodeComponent, {
restoreFocus: true,
data: {
providerName: provider.id,
url: authUrl,
},
})
.afterClosed()
.toPromise();
if (authCode) {
const r = await verifyCodeChallenge(authCode);
// Preserve existing config (especially encryptKey) when updating auth
const existingConfig = await provider.privateCfg.load();
await this._providerManager.setProviderConfig(provider.id, {
...existingConfig,
...r,
});
// NOTE: exec sync afterward; promise not awaited
setTimeout(() => {
this.sync();
}, 1000);
return { wasConfigured: true };
} else {
return { wasConfigured: false };
}
}
} catch (error) {
SyncLog.err(error);
this._snackService.open({
// TODO don't limit snack to dropbox
msg: T.F.DROPBOX.S.UNABLE_TO_GENERATE_PKCE_CHALLENGE,
type: 'ERROR',
});
return { wasConfigured: false };
}
return { wasConfigured: false };
}
/**
* Handle incoherent timestamps dialog with proper async error handling.
* Uses fire-and-forget pattern but logs errors instead of swallowing them.
*/
private _handleIncoherentTimestampsDialog(): void {
const dialogRef = this._matDialog.open(DialogIncoherentTimestampsErrorComponent, {
disableClose: true,
autoFocus: false,
});
// Use firstValueFrom for proper async handling
firstValueFrom(dialogRef.afterClosed())
.then(async (res) => {
if (res === 'FORCE_UPDATE_REMOTE') {
await this.forceUpload();
} else if (res === 'FORCE_UPDATE_LOCAL') {
// Op-log architecture handles this differently
SyncLog.log(
'SyncWrapperService: forceDownload called (delegated to op-log sync)',
);
}
})
.catch((err) => {
SyncLog.err('Error handling incoherent timestamps dialog result:', err);
});
}
/**
* Handle incomplete sync dialog with proper async error handling.
* Uses fire-and-forget pattern but logs errors instead of swallowing them.
*/
private _handleIncompleteSyncDialog(modelId: string | undefined): void {
const dialogRef = this._matDialog.open(DialogIncompleteSyncComponent, {
data: { modelId },
disableClose: true,
autoFocus: false,
});
// Use firstValueFrom for proper async handling
firstValueFrom(dialogRef.afterClosed())
.then(async (res) => {
if (res === 'FORCE_UPDATE_REMOTE') {
await this.forceUpload();
}
})
.catch((err) => {
SyncLog.err('Error handling incomplete sync dialog result:', err);
});
}
/**
* Safely extract modelId from error with proper type validation.
*/
private _extractModelIdFromError(
error: RevMismatchForModelError | NoRemoteModelFile,
): string | undefined {
if (!error.additionalLog) {
return undefined;
}
// Handle both array and string formats
if (Array.isArray(error.additionalLog) && error.additionalLog.length > 0) {
const firstItem = error.additionalLog[0];
return typeof firstItem === 'string' ? firstItem : undefined;
}
if (typeof error.additionalLog === 'string') {
return error.additionalLog;
}
return undefined;
}
private _handleDecryptionError(): void {
this._matDialog
.open(DialogHandleDecryptErrorComponent, {
disableClose: true,
autoFocus: false,
})
.afterClosed()
.subscribe(({ isReSync, isForceUpload }) => {
if (isReSync) {
this.sync();
}
if (isForceUpload) {
this.forceUpload();
}
});
}
/**
* Handles LocalDataConflictError by showing a conflict resolution dialog.
* This occurs when file-based sync (Dropbox, WebDAV) detects local unsynced
* changes that would be lost if the remote snapshot is applied.
*
* User can choose:
* - USE_LOCAL: Upload local data, overwriting remote (uses forceUploadLocalState)
* - USE_REMOTE: Download remote data, discarding local (uses forceDownloadRemoteState)
*/
private async _handleLocalDataConflict(
error: LocalDataConflictError,
): Promise<SyncStatus | 'HANDLED_ERROR'> {
// Signal that we're waiting for user input (prevents sync timeout)
const stopWaiting = this._userInputWaitState.startWaiting('local-data-conflict');
try {
// Build ConflictData for the dialog
const vcEntry = await this._opLogStore.getVectorClockEntry();
const localClock = vcEntry?.clock;
const localLastUpdate = vcEntry?.lastUpdate || Date.now();
const conflictData: ConflictData = {
reason: ConflictReason.NoLastSync,
remote: {
lastUpdate: Date.now(), // Remote snapshot doesn't have a timestamp, use now
lastUpdateAction: 'Remote data',
revMap: {},
crossModelVersion: 1,
mainModelData: error.remoteSnapshotState,
isFullData: true,
vectorClock: error.remoteVectorClock,
},
local: {
lastUpdate: localLastUpdate,
lastUpdateAction: `${error.unsyncedCount} local changes pending`,
revMap: {},
crossModelVersion: 1,
lastSyncedUpdate: null,
metaRev: null,
vectorClock: localClock,
lastSyncedVectorClock: null,
},
};
SyncLog.log(
`SyncWrapperService: Showing conflict dialog for ${error.unsyncedCount} local changes vs remote snapshot`,
);
const resolution = await firstValueFrom(this._openConflictDialog$(conflictData));
// Get sync provider for the resolution operation
const rawProvider = this._providerManager.getActiveProvider();
const syncCapableProvider =
await this._wrappedProvider.getOperationSyncCapable(rawProvider);
if (!syncCapableProvider) {
SyncLog.err(
'SyncWrapperService: Cannot resolve conflict - provider not available',
);
return 'HANDLED_ERROR';
}
if (resolution === 'USE_LOCAL') {
// User chose to keep local data and upload it to remote
SyncLog.log(
'SyncWrapperService: User chose USE_LOCAL - uploading local state to overwrite remote',
);
await this._opLogSyncService.forceUploadLocalState(syncCapableProvider);
this._providerManager.setSyncStatus('IN_SYNC');
return SyncStatus.InSync;
} else if (resolution === 'USE_REMOTE') {
// User chose to discard local data and download remote
SyncLog.log(
'SyncWrapperService: User chose USE_REMOTE - downloading remote state, discarding local',
);
await this._opLogSyncService.forceDownloadRemoteState(syncCapableProvider);
this._providerManager.setSyncStatus('IN_SYNC');
return SyncStatus.InSync;
} else {
// User cancelled the dialog
SyncLog.log('SyncWrapperService: User cancelled first sync conflict dialog');
this._snackService.open({
msg: T.F.SYNC.S.LOCAL_DATA_REPLACE_CANCELLED,
});
return 'HANDLED_ERROR';
}
} catch (resolutionError) {
// Error during conflict resolution (forceUpload or forceDownload failed)
SyncLog.err(
'SyncWrapperService: Error during conflict resolution:',
resolutionError,
);
const errStr = getSyncErrorStr(resolutionError);
this._snackService.open({
msg: errStr,
type: 'ERROR',
});
return 'HANDLED_ERROR';
} finally {
stopWaiting();
}
}
private async _reInitAppAfterDataModelChange(
downloadedMainModelData?: Record<string, unknown>,
): Promise<void> {
SyncLog.log('Starting data re-initialization after sync...');
try {
await Promise.all([
// Use reInitFromRemoteSync() which now uses the passed downloaded data
// instead of reading from IndexedDB (entity models aren't stored there)
this._dataInitService.reInitFromRemoteSync(downloadedMainModelData),
]);
// wait an extra frame to potentially avoid follow up problems
await promiseTimeout(SYNC_REINIT_DELAY_MS);
SyncLog.log('Data re-initialization complete');
// Signal that data reload is complete
} catch (error) {
SyncLog.err('Error during data re-initialization:', error);
throw error;
}
}
private _c(str: string): boolean {
return confirm(this._translateService.instant(str));
}
private _isPermissionError(error: unknown): boolean {
const errStr = String(error);
return /EROFS|EACCES|EPERM|read-only file system|permission denied/i.test(errStr);
}
private _getPermissionErrorMessage(): string {
if (IS_ELECTRON && window.ea?.isFlatpak?.()) {
return T.F.SYNC.S.ERROR_PERMISSION_FLATPAK;
}
if (IS_ELECTRON && window.ea?.isSnap?.()) {
return T.F.SYNC.S.ERROR_PERMISSION_SNAP;
}
return T.F.SYNC.S.ERROR_PERMISSION;
}
private lastConflictDialog?: MatDialogRef<any, any>;
private _openConflictDialog$(
conflictData: ConflictData,
): Observable<DialogConflictResolutionResult> {
if (this.lastConflictDialog) {
this.lastConflictDialog.close();
}
this.lastConflictDialog = this._matDialog.open(DialogSyncConflictComponent, {
restoreFocus: true,
autoFocus: false,
disableClose: true,
data: conflictData,
});
return this.lastConflictDialog.afterClosed();
}
/**
* Syncs the current vector clock from SUP_OPS to pf.META_MODEL.
* Called before legacy sync providers start syncing.
* This ensures the legacy sync provider sees the latest vector clock.
*/
private async _syncVectorClockToPfapi(): Promise<void> {
const vcEntry = await this._opLogStore.getVectorClockEntry();
if (vcEntry) {
SyncLog.log('[SyncWrapper] Syncing vector clock to pf.META_MODEL', {
clockSize: Object.keys(vcEntry.clock).length,
lastUpdate: vcEntry.lastUpdate,
});
const existing = await this._legacyPfDb.loadMetaModel();
await this._legacyPfDb.saveMetaModel({
...existing,
vectorClock: vcEntry.clock,
lastUpdate: vcEntry.lastUpdate,
});
} else {
SyncLog.log('[SyncWrapper] No vector clock in SUP_OPS, skipping sync');
}
}
/**
* Syncs the vector clock from pf.META_MODEL to SUP_OPS.vector_clock.
* Called after downloading data from remote (UpdateLocal/UpdateLocalAll).
* This ensures SUP_OPS has the latest vector clock from the remote,
* so subsequent syncs correctly detect changes.
*/
private async _syncVectorClockFromPfapi(): Promise<void> {
const metaModel = await this._legacyPfDb.loadMetaModel();
if (metaModel?.vectorClock && Object.keys(metaModel.vectorClock).length > 0) {
SyncLog.log('[SyncWrapper] Syncing vector clock from pf.META_MODEL to SUP_OPS', {
clockSize: Object.keys(metaModel.vectorClock).length,
lastUpdate: metaModel.lastUpdate,
});
await this._opLogStore.setVectorClock(metaModel.vectorClock);
} else {
SyncLog.log(
'[SyncWrapper] No vector clock in pf.META_MODEL, skipping sync to SUP_OPS',
);
}
}
}