mirror of
https://github.com/johannesjo/super-productivity.git
synced 2026-07-17 16:37:43 +00:00
refactor(sync): remove unused error classes, dialog, and constructor-time logging (phase 1, #8325) (#8510)
* fix(op-log): lock snapshot save to prevent lost-update window saveCurrentStateAsSnapshot() read NgRx state then lastSeq without holding OPERATION_LOG lock. An op appended between the two reads would get seq <= lastAppliedOpSeq but its effect would be absent from the snapshot. On next hydration the tail replay would start after that seq, silently skipping the op forever. Fix: wrap in lockService.request(LOCK_NAMES.OPERATION_LOG, ...) and read lastSeq BEFORE state snapshot so the worst interleaving degrades to harmless re-replay (idempotent) rather than a missed op. Fixes #8308 * fix(op-log): address review feedback on snapshot lock PR - Amend JSDoc idempotency claim: syncTimeSpent is additive on re-replay - Add inline note about compaction's opposite read order and worse failure mode - Add lock regression tests (#8308): lock acquired, read order, error handling Co-Authored-By: Claude <noreply@anthropic.com> * refactor(sync): remove unused error classes, dialog, and constructor-time logging Phase 1 of #8325: clean up orphaned sync error types and their UI. Removed error classes that are no longer thrown anywhere: - NoEtagAPIError, FileExistsAPIError (unused API errors) - RevMismatchForModelError, SyncInvalidTimeValuesError (superseded by file-based flow) - RevMapModelMismatchErrorOnDownload/Upload, NoRemoteModelFile, NoRemoteMetaFile - LockPresentError, LockFromLocalClientPresentError, MetaNotReadyError, InvalidRevMapError Removed DialogSyncErrorComponent and all references in SyncWrapperService (_forceDownload, _handleIncoherentTimestampsDialog, _handleIncompleteSyncDialog, _openSyncErrorDialog, _extractModelIdFromError). Removed constructor-time logging from JsonParseError, ModelValidationError, DataValidationFailedError — these errors are logged at the catch site; redundant construction-time logs risk leaking user data. Cleaned up dead translation keys (D_INCOMPLETE_SYNC block, DIALOG_RESULT_ERROR, ERROR_DATA_IS_CURRENTLY_WRITTEN) from en.json and t.const.ts. Updated file-based-sync-flowchart.md to reflect the removed error types. --------- Co-authored-by: Claude <noreply@anthropic.com>
This commit is contained in:
parent
b8d1dbe261
commit
8171bb05d0
15 changed files with 38 additions and 507 deletions
|
|
@ -149,7 +149,7 @@ export interface PlainspaceCfg extends BaseIssueProviderCfg {
|
|||
> deliberate parity choice (a provider works on a fresh device after sync without
|
||||
> re-pasting), and is the accepted secret-handling posture for issue providers.
|
||||
> The account store (§3.3, local-only `localStorage`) holds a token **too**, but
|
||||
> only to bootstrap the "Share on Plainspace" flow, which needs a token *before*
|
||||
> only to bootstrap the "Share on Plainspace" flow, which needs a token _before_
|
||||
> any provider exists; the provider runtime reads only `cfg.token`. An earlier
|
||||
> draft said the token was not stored in the cfg — that was never the case in the
|
||||
> shipped code.
|
||||
|
|
|
|||
|
|
@ -121,9 +121,6 @@ flowchart LR
|
|||
TYPE -->|DecryptNoPasswordError| PWD_DLG[Enter Password dialog]
|
||||
TYPE -->|DecryptError| DEC_DLG[Decrypt Error dialog]
|
||||
TYPE -->|LocalDataConflictError| CONF_DLG[SyncConflictDialog]
|
||||
TYPE -->|RevMismatchForModelError<br/>NoRemoteModelFile| INC_DLG["Incomplete sync" dialog:<br/>Force Upload / Force Download]
|
||||
TYPE -->|SyncInvalidTimeValuesError| TIME_DLG["Incoherent timestamps" dialog:<br/>Force Upload / Force Download]
|
||||
TYPE -->|LockPresentError| LOCK[Snackbar + Force Overwrite action]
|
||||
TYPE -->|PotentialCorsError| CORS[CORS error snackbar]
|
||||
TYPE -->|AuthFail / MissingCredentials| AUTH[Auth error snackbar<br/>+ Configure action]
|
||||
TYPE -->|WebCryptoNotAvailable| CRYPTO[WebCrypto snackbar]
|
||||
|
|
@ -134,7 +131,7 @@ flowchart LR
|
|||
classDef dialog fill:#48f,stroke:#26d,color:#fff
|
||||
classDef snack fill:#f90,stroke:#b60,color:#fff
|
||||
|
||||
class PWD_DLG,DEC_DLG,CONF_DLG,INC_DLG,TIME_DLG dialog
|
||||
class PWD_DLG,DEC_DLG,CONF_DLG dialog
|
||||
class LOCK,CORS,AUTH,CRYPTO,TIMEOUT,PERM,GENERIC snack
|
||||
```
|
||||
|
||||
|
|
@ -148,16 +145,16 @@ flowchart LR
|
|||
|
||||
## Key Differences from SuperSync
|
||||
|
||||
| Aspect | File-Based (Dropbox, WebDAV, LocalFile) | SuperSync |
|
||||
|--------|----------------------------------------|-----------|
|
||||
| **Transport** | Downloads/uploads a single `sync-data.json` file | Paginated API (server-side op log) |
|
||||
| **Snapshot path** | Full `snapshotState` on seq 0 download, with its own conflict-checking flow | No snapshot concept — all ops are incremental |
|
||||
| **Gap detection** | Adapter detects syncVersion reset / snapshot replacement / partial trimming → re-download from seq 0 | Server handles gap detection internally |
|
||||
| **Server migration** | Gap on empty server → `needsFullStateUpload` → `handleServerMigration()` | Same concept but detected via different mechanism |
|
||||
| **Upload retry** | Rev matching (ETag) + exponential backoff with jitter | Server rejection codes (`CONFLICT_CONCURRENT`) |
|
||||
| **Piggybacking** | Not applicable — no server to piggyback. Concurrent changes are discovered on re-download during retry. | Server returns piggybacked ops in upload response |
|
||||
| **Post-sync encryption prompt** | Not applicable | Prompts user to set password or disable sync |
|
||||
| **File-based error types** | `RevMismatchForModelError`, `NoRemoteModelFile`, `SyncInvalidTimeValuesError`, `LockPresentError`, `PotentialCorsError` | Not applicable |
|
||||
| Aspect | File-Based (Dropbox, WebDAV, LocalFile) | SuperSync |
|
||||
| ------------------------------- | ------------------------------------------------------------------------------------------------------- | ------------------------------------------------- |
|
||||
| **Transport** | Downloads/uploads a single `sync-data.json` file | Paginated API (server-side op log) |
|
||||
| **Snapshot path** | Full `snapshotState` on seq 0 download, with its own conflict-checking flow | No snapshot concept — all ops are incremental |
|
||||
| **Gap detection** | Adapter detects syncVersion reset / snapshot replacement / partial trimming → re-download from seq 0 | Server handles gap detection internally |
|
||||
| **Server migration** | Gap on empty server → `needsFullStateUpload` → `handleServerMigration()` | Same concept but detected via different mechanism |
|
||||
| **Upload retry** | Rev matching (ETag) + exponential backoff with jitter | Server rejection codes (`CONFLICT_CONCURRENT`) |
|
||||
| **Piggybacking** | Not applicable — no server to piggyback. Concurrent changes are discovered on re-download during retry. | Server returns piggybacked ops in upload response |
|
||||
| **Post-sync encryption prompt** | Not applicable | Prompts user to set password or disable sync |
|
||||
| **File-based error types** | `PotentialCorsError`, `LegacySyncFormatDetectedError`, `SyncDataCorruptedError` | Not applicable |
|
||||
|
||||
## Notes
|
||||
|
||||
|
|
@ -169,9 +166,9 @@ flowchart LR
|
|||
|
||||
## Key Source Files
|
||||
|
||||
| File | Role |
|
||||
|------|------|
|
||||
| `src/app/imex/sync/sync-wrapper.service.ts` | Top-level orchestration + error handling |
|
||||
| `src/app/op-log/sync/operation-log-sync.service.ts` | Download/upload orchestration, fresh client checks, SYNC_IMPORT handling |
|
||||
| `src/app/op-log/sync/operation-log-download.service.ts` | Download + internal gap detection |
|
||||
| `src/app/op-log/sync-providers/file-based/file-based-sync-adapter.service.ts` | File adapter (rev matching, gap detection, snapshot upload) |
|
||||
| File | Role |
|
||||
| ----------------------------------------------------------------------------- | ------------------------------------------------------------------------ |
|
||||
| `src/app/imex/sync/sync-wrapper.service.ts` | Top-level orchestration + error handling |
|
||||
| `src/app/op-log/sync/operation-log-sync.service.ts` | Download/upload orchestration, fresh client checks, SYNC_IMPORT handling |
|
||||
| `src/app/op-log/sync/operation-log-download.service.ts` | Download + internal gap detection |
|
||||
| `src/app/op-log/sync-providers/file-based/file-based-sync-adapter.service.ts` | File adapter (rev matching, gap detection, snapshot upload) |
|
||||
|
|
|
|||
|
|
@ -4,11 +4,9 @@ const path = require('node:path');
|
|||
|
||||
require('ts-node/register/transpile-only');
|
||||
|
||||
const {
|
||||
isGnomeDesktopEnv,
|
||||
isWaylandEnv,
|
||||
isGnomeWaylandEnv,
|
||||
} = require(path.resolve(__dirname, 'common.const.ts'));
|
||||
const { isGnomeDesktopEnv, isWaylandEnv, isGnomeWaylandEnv } = require(
|
||||
path.resolve(__dirname, 'common.const.ts'),
|
||||
);
|
||||
|
||||
test('isGnomeDesktopEnv: detects GNOME from desktop env vars', () => {
|
||||
assert.equal(isGnomeDesktopEnv('linux', { XDG_CURRENT_DESKTOP: 'GNOME' }), true);
|
||||
|
|
@ -56,7 +54,10 @@ test('isGnomeWaylandEnv: only true for GNOME AND Wayland together', () => {
|
|||
);
|
||||
// non-GNOME Wayland (e.g. KDE) is unaffected
|
||||
assert.equal(
|
||||
isGnomeWaylandEnv('linux', { XDG_CURRENT_DESKTOP: 'KDE', XDG_SESSION_TYPE: 'wayland' }),
|
||||
isGnomeWaylandEnv('linux', {
|
||||
XDG_CURRENT_DESKTOP: 'KDE',
|
||||
XDG_SESSION_TYPE: 'wayland',
|
||||
}),
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -254,10 +254,7 @@ test('initIndicator uses NativeImage for Linux tray creation and updates', () =>
|
|||
// On Linux the running icon stays static (no progress-animation frames) to
|
||||
// avoid StatusNotifierItem flicker (#4905).
|
||||
assert.equal(traySetImageCalls.at(-1).kind, 'native-image');
|
||||
assert.match(
|
||||
traySetImageCalls.at(-1).iconPath,
|
||||
/\/icons\/indicator\/running-d\.png$/,
|
||||
);
|
||||
assert.match(traySetImageCalls.at(-1).iconPath, /\/icons\/indicator\/running-d\.png$/);
|
||||
|
||||
beforeQuitHandler();
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,90 +0,0 @@
|
|||
<mat-dialog-content>
|
||||
<div style="text-align: center; margin-bottom: 16px">
|
||||
<mat-icon
|
||||
color="warn"
|
||||
style="font-size: 48px; height: 48px; width: 48px"
|
||||
aria-hidden="true"
|
||||
>error
|
||||
</mat-icon>
|
||||
</div>
|
||||
|
||||
@if (data.type === 'incomplete-sync') {
|
||||
<p>
|
||||
<strong> {{ T.F.SYNC.D_INCOMPLETE_SYNC.P1 | translate }} </strong>
|
||||
</p>
|
||||
|
||||
<p>
|
||||
{{ T.F.SYNC.D_INCOMPLETE_SYNC.P2 | translate }}
|
||||
<strong>{{ data.modelId }}</strong>
|
||||
</p>
|
||||
|
||||
<p>{{ T.F.SYNC.D_INCOMPLETE_SYNC.T3 | translate }}</p>
|
||||
|
||||
<p>{{ T.F.SYNC.D_INCOMPLETE_SYNC.T4 | translate }}</p>
|
||||
<p>{{ T.F.SYNC.D_INCOMPLETE_SYNC.T5 | translate }}</p>
|
||||
|
||||
<p style="text-align: center; margin-top: 16px">
|
||||
<strong> {{ T.F.SYNC.D_INCOMPLETE_SYNC.T6 | translate }} </strong>
|
||||
</p>
|
||||
} @else {
|
||||
<p>
|
||||
<strong>{{ T.F.SYNC.D_INCOMPLETE_SYNC.INCOHERENT_P1 | translate }}</strong>
|
||||
</p>
|
||||
<p>{{ T.F.SYNC.D_INCOMPLETE_SYNC.INCOHERENT_P2 | translate }}</p>
|
||||
}
|
||||
|
||||
<div style="text-align: center; margin-bottom: 16px">
|
||||
<button
|
||||
(click)="downloadBackup()"
|
||||
color="primary"
|
||||
mat-stroked-button
|
||||
type="button"
|
||||
>
|
||||
<mat-icon aria-hidden="true">file_download</mat-icon>
|
||||
{{ T.F.SYNC.D_INCOMPLETE_SYNC.BTN_DOWNLOAD_BACKUP | translate }}
|
||||
</button>
|
||||
</div>
|
||||
</mat-dialog-content>
|
||||
|
||||
<mat-dialog-actions align="end">
|
||||
<div class="wrap-buttons">
|
||||
<button
|
||||
(click)="close()"
|
||||
color="primary"
|
||||
mat-button
|
||||
>
|
||||
{{ T.G.CANCEL | translate }}
|
||||
</button>
|
||||
|
||||
@if (data.type === 'incomplete-sync' && !IS_ANDROID_WEB_VIEW) {
|
||||
<button
|
||||
(click)="closeApp()"
|
||||
color="primary"
|
||||
mat-stroked-button
|
||||
>
|
||||
<mat-icon aria-hidden="true">power_settings_new</mat-icon>
|
||||
{{ T.F.SYNC.D_INCOMPLETE_SYNC.BTN_CLOSE_APP | translate }}
|
||||
</button>
|
||||
}
|
||||
|
||||
@if (data.type === 'incoherent-timestamps') {
|
||||
<button
|
||||
(click)="close('FORCE_UPDATE_LOCAL')"
|
||||
color="warn"
|
||||
mat-stroked-button
|
||||
>
|
||||
<mat-icon aria-hidden="true">file_download</mat-icon>
|
||||
{{ T.F.SYNC.D_INCOMPLETE_SYNC.BTN_FORCE_DOWNLOAD | translate }}
|
||||
</button>
|
||||
}
|
||||
|
||||
<button
|
||||
(click)="close('FORCE_UPDATE_REMOTE')"
|
||||
color="warn"
|
||||
mat-stroked-button
|
||||
>
|
||||
<mat-icon aria-hidden="true">file_upload</mat-icon>
|
||||
{{ T.F.SYNC.D_INCOMPLETE_SYNC.BTN_FORCE_UPLOAD | translate }}
|
||||
</button>
|
||||
</div>
|
||||
</mat-dialog-actions>
|
||||
|
|
@ -1,7 +0,0 @@
|
|||
:host > * {
|
||||
max-width: 500px;
|
||||
}
|
||||
|
||||
:host code {
|
||||
font-size: 10px;
|
||||
}
|
||||
|
|
@ -1,76 +0,0 @@
|
|||
import { ChangeDetectionStrategy, Component, inject } from '@angular/core';
|
||||
import {
|
||||
MAT_DIALOG_DATA,
|
||||
MatDialogActions,
|
||||
MatDialogContent,
|
||||
MatDialogRef,
|
||||
} from '@angular/material/dialog';
|
||||
import { TranslateModule } from '@ngx-translate/core';
|
||||
import { download } from '../../../util/download';
|
||||
import {
|
||||
BACKUP_FILENAME_PREFIX,
|
||||
getBackupTimestamp,
|
||||
} from '../../../../../electron/shared-with-frontend/get-backup-timestamp';
|
||||
|
||||
import { IS_ELECTRON } from '../../../app.constants';
|
||||
import { IS_ANDROID_WEB_VIEW } from '../../../util/is-android-web-view';
|
||||
import { MatIcon } from '@angular/material/icon';
|
||||
import { MatButton } from '@angular/material/button';
|
||||
import { BackupService } from '../../../op-log/backup/backup.service';
|
||||
import { T } from '../../../t.const';
|
||||
import { Log } from '../../../core/log';
|
||||
|
||||
export type DialogSyncErrorType = 'incomplete-sync' | 'incoherent-timestamps';
|
||||
|
||||
export interface DialogSyncErrorData {
|
||||
type: DialogSyncErrorType;
|
||||
modelId?: string;
|
||||
}
|
||||
|
||||
export type DialogSyncErrorResult =
|
||||
| 'FORCE_UPDATE_REMOTE'
|
||||
| 'FORCE_UPDATE_LOCAL'
|
||||
| undefined;
|
||||
|
||||
@Component({
|
||||
selector: 'dialog-sync-error',
|
||||
imports: [MatDialogContent, TranslateModule, MatIcon, MatDialogActions, MatButton],
|
||||
templateUrl: './dialog-sync-error.component.html',
|
||||
styleUrl: './dialog-sync-error.component.scss',
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
})
|
||||
export class DialogSyncErrorComponent {
|
||||
private _matDialogRef = inject<MatDialogRef<DialogSyncErrorComponent>>(MatDialogRef);
|
||||
private _backupService = inject(BackupService);
|
||||
|
||||
data = inject<DialogSyncErrorData>(MAT_DIALOG_DATA);
|
||||
|
||||
T: typeof T = T;
|
||||
IS_ANDROID_WEB_VIEW = IS_ANDROID_WEB_VIEW;
|
||||
|
||||
constructor() {
|
||||
this._matDialogRef.disableClose = true;
|
||||
}
|
||||
|
||||
async downloadBackup(): Promise<void> {
|
||||
const data = await this._backupService.loadCompleteBackup(true);
|
||||
try {
|
||||
const fileName = `${BACKUP_FILENAME_PREFIX}_${getBackupTimestamp()}.json`;
|
||||
await download(fileName, JSON.stringify(data));
|
||||
} catch (e) {
|
||||
Log.error(e);
|
||||
}
|
||||
}
|
||||
|
||||
close(res?: DialogSyncErrorResult): void {
|
||||
this._matDialogRef.close(res);
|
||||
}
|
||||
|
||||
closeApp(): void {
|
||||
if (IS_ELECTRON) {
|
||||
window.ea.shutdownNow();
|
||||
} else {
|
||||
window.close();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -36,13 +36,9 @@ import {
|
|||
ConflictReason,
|
||||
DecryptError,
|
||||
DecryptNoPasswordError,
|
||||
LockPresentError,
|
||||
MissingCredentialsSPError,
|
||||
NetworkUnavailableSPError,
|
||||
NoRemoteModelFile,
|
||||
PotentialCorsError,
|
||||
RevMismatchForModelError,
|
||||
SyncInvalidTimeValuesError,
|
||||
SyncProviderId,
|
||||
SyncStatus,
|
||||
toSyncProviderId,
|
||||
|
|
@ -58,10 +54,6 @@ import { ReminderService } from '../../features/reminder/reminder.service';
|
|||
|
||||
import { DialogHandleDecryptErrorComponent } from './dialog-handle-decrypt-error/dialog-handle-decrypt-error.component';
|
||||
import { DialogEnterEncryptionPasswordComponent } from './dialog-enter-encryption-password/dialog-enter-encryption-password.component';
|
||||
import {
|
||||
DialogSyncErrorComponent,
|
||||
DialogSyncErrorResult,
|
||||
} from './dialog-sync-error/dialog-sync-error.component';
|
||||
import { SyncLog } from '../../core/log';
|
||||
import { promiseTimeout } from '../../util/promise-timeout';
|
||||
import { devError } from '../../util/dev-error';
|
||||
|
|
@ -90,11 +82,9 @@ type CompletedUploadOutcome = Extract<UploadOutcome, { kind: 'completed' }>;
|
|||
* back to its origin without diff-archaeology.
|
||||
*/
|
||||
export type ForceUploadTriggerSource =
|
||||
| 'LockPresentError'
|
||||
| 'EmptyRemoteBodySPError'
|
||||
| 'JsonParseError'
|
||||
| 'LegacySyncFormatDetectedError'
|
||||
| 'DialogSyncError'
|
||||
| 'DecryptError'
|
||||
| 'unknown';
|
||||
|
||||
|
|
@ -719,29 +709,6 @@ export class SyncWrapperService {
|
|||
});
|
||||
}
|
||||
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('LockPresentError'),
|
||||
actionStr: T.F.SYNC.S.BTN_FORCE_OVERWRITE,
|
||||
});
|
||||
return 'HANDLED_ERROR';
|
||||
} else if (error instanceof EmptyRemoteBodySPError) {
|
||||
// Remote file returned an empty body (e.g. Koofr WebDAV corrupted file).
|
||||
// Force overwrite is safe: local data is intact, remote is empty.
|
||||
|
|
@ -965,7 +932,7 @@ export class SyncWrapperService {
|
|||
await this.runWithSyncBlocked(async () => {
|
||||
// #8309: claim the sync cycle so this flow's setLastServerSeq bookkeeping
|
||||
// and session-validation latch are isolated from the immediate-upload /
|
||||
// WS-download side channels, mirroring _forceDownload. runWithSyncBlocked
|
||||
// WS-download side channels. runWithSyncBlocked
|
||||
// has already drained any main sync and set isEncryptionOperationInProgress
|
||||
// (which the side channels honour), so the only thing that could hold the
|
||||
// guard here is a short-lived side channel; skip-and-let-the-user-retry
|
||||
|
|
@ -1002,65 +969,6 @@ export class SyncWrapperService {
|
|||
});
|
||||
}
|
||||
|
||||
private async _forceDownload(): Promise<void> {
|
||||
SyncLog.log('SyncWrapperService: forceDownload called - downloading remote state');
|
||||
|
||||
await this.runWithSyncBlocked(async () => {
|
||||
// #8309: claim the sync cycle so this flow's conflict-gate / session
|
||||
// latch are isolated from the immediate-upload / WS-download side
|
||||
// channels. runWithSyncBlocked has already drained any main sync and set
|
||||
// isEncryptionOperationInProgress, so the only thing that could hold the
|
||||
// guard here is a short-lived side channel; skip-and-let-the-user-retry
|
||||
// rather than block.
|
||||
if (!this._syncCycleGuard.tryBegin()) {
|
||||
SyncLog.log('Force download skipped: another sync cycle is in progress');
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await this._forceDownloadInner();
|
||||
} finally {
|
||||
this._syncCycleGuard.end();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private async _forceDownloadInner(): Promise<void> {
|
||||
// Open a session-validation scope — read after forceDownloadRemoteState
|
||||
// returns so a corrupt downloaded state is reported as ERROR. (#7330)
|
||||
await this._sessionValidation.withSession(async () => {
|
||||
try {
|
||||
const rawProvider = this._providerManager.getActiveProvider();
|
||||
const syncCapableProvider =
|
||||
await this._wrappedProvider.getOperationSyncCapable(rawProvider);
|
||||
|
||||
if (!syncCapableProvider) {
|
||||
SyncLog.warn(
|
||||
'SyncWrapperService: Cannot force download - provider not available',
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
await this._opLogSyncService.forceDownloadRemoteState(syncCapableProvider);
|
||||
if (this._sessionValidation.hasFailed()) {
|
||||
SyncLog.err(
|
||||
'SyncWrapperService: Force download applied but post-sync validation failed; reporting ERROR',
|
||||
);
|
||||
this._providerManager.setSyncStatus('ERROR');
|
||||
} else {
|
||||
this._providerManager.setSyncStatus('IN_SYNC');
|
||||
}
|
||||
SyncLog.log('SyncWrapperService: Force download complete');
|
||||
} catch (error) {
|
||||
SyncLog.err('SyncWrapperService: Force download failed:', error);
|
||||
const errStr = getSyncErrorStr(error);
|
||||
this._snackService.open({
|
||||
msg: errStr,
|
||||
type: 'ERROR',
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async configuredAuthForSyncProviderIfNecessary(
|
||||
providerId: SyncProviderId,
|
||||
force = false,
|
||||
|
|
@ -1127,70 +1035,12 @@ export class SyncWrapperService {
|
|||
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 async _openSyncCfgDialog(): Promise<void> {
|
||||
const { DialogSyncCfgComponent } =
|
||||
await import('./dialog-sync-cfg/dialog-sync-cfg.component');
|
||||
this._matDialog.open(DialogSyncCfgComponent);
|
||||
}
|
||||
|
||||
private _handleIncoherentTimestampsDialog(): void {
|
||||
this._openSyncErrorDialog({ type: 'incoherent-timestamps' });
|
||||
}
|
||||
|
||||
private _handleIncompleteSyncDialog(modelId: string | undefined): void {
|
||||
this._openSyncErrorDialog({ type: 'incomplete-sync', modelId });
|
||||
}
|
||||
|
||||
private _openSyncErrorDialog(data: {
|
||||
type: 'incomplete-sync' | 'incoherent-timestamps';
|
||||
modelId?: string;
|
||||
}): void {
|
||||
const dialogRef = this._matDialog.open(DialogSyncErrorComponent, {
|
||||
data,
|
||||
disableClose: true,
|
||||
});
|
||||
|
||||
firstValueFrom(dialogRef.afterClosed())
|
||||
.then(async (res: DialogSyncErrorResult) => {
|
||||
if (res === 'FORCE_UPDATE_REMOTE') {
|
||||
await this.forceUpload('DialogSyncError');
|
||||
} else if (res === 'FORCE_UPDATE_LOCAL') {
|
||||
await this._forceDownload();
|
||||
}
|
||||
})
|
||||
.catch((err) => {
|
||||
SyncLog.err('Error handling sync error dialog result:', err);
|
||||
this._snackService.open({
|
||||
type: 'ERROR',
|
||||
msg: T.F.SYNC.S.DIALOG_RESULT_ERROR,
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 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;
|
||||
}
|
||||
|
||||
/**
|
||||
* Handles missing encryption password when receiving encrypted data.
|
||||
* Opens a simple dialog to prompt for the password, then re-syncs.
|
||||
|
|
|
|||
|
|
@ -51,20 +51,16 @@ describe('sync errors', () => {
|
|||
expect((OpLog.log as jasmine.Spy).calls.count()).toBe(0);
|
||||
});
|
||||
|
||||
it('does not log JSON parse data samples or raw original errors', () => {
|
||||
it('does not log on construction for JsonParseError (privacy invariant)', () => {
|
||||
new JsonParseError(
|
||||
new SyntaxError('Unexpected token SECRET at position 6'),
|
||||
'{"a":"secret value"}',
|
||||
);
|
||||
|
||||
const errText = JSON.stringify((OpLog.err as jasmine.Spy).calls.allArgs());
|
||||
expect(errText).toContain('JsonParseError');
|
||||
expect(errText).toContain('dataLength');
|
||||
expect(errText).not.toContain('SECRET');
|
||||
expect(errText).not.toContain('secret value');
|
||||
expect((OpLog.err as jasmine.Spy).calls.count()).toBe(0);
|
||||
});
|
||||
|
||||
it('logs model validation diagnostics without validation payloads', () => {
|
||||
it('does not log on construction for ModelValidationError (privacy invariant)', () => {
|
||||
const validationResult = {
|
||||
success: false,
|
||||
errors: [
|
||||
|
|
@ -83,15 +79,10 @@ describe('sync errors', () => {
|
|||
e: new Error('secret validation failure'),
|
||||
});
|
||||
|
||||
const logText = JSON.stringify((OpLog.log as jasmine.Spy).calls.allArgs());
|
||||
expect(logText).toContain('ModelValidationError');
|
||||
expect(logText).toContain('task-id-1');
|
||||
expect(logText).toContain('validationErrorCount');
|
||||
expect(logText).not.toContain('secret title');
|
||||
expect(logText).not.toContain('secret validation failure');
|
||||
expect((OpLog.log as jasmine.Spy).calls.count()).toBe(0);
|
||||
});
|
||||
|
||||
it('logs data validation diagnostics without validation payloads', () => {
|
||||
it('does not log on construction for DataValidationFailedError (privacy invariant)', () => {
|
||||
const validationResult = {
|
||||
success: false,
|
||||
errors: [
|
||||
|
|
@ -105,10 +96,6 @@ describe('sync errors', () => {
|
|||
|
||||
new DataValidationFailedError(validationResult);
|
||||
|
||||
const logText = JSON.stringify((OpLog.log as jasmine.Spy).calls.allArgs());
|
||||
expect(logText).toContain('DataValidationFailedError');
|
||||
expect(logText).toContain('validationErrorCount');
|
||||
expect(logText).toContain('$input.notes');
|
||||
expect(logText).not.toContain('secret note text');
|
||||
expect((OpLog.log as jasmine.Spy).calls.count()).toBe(0);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,12 +1,10 @@
|
|||
import { IValidation } from 'typia';
|
||||
import type { SyncFilePrefixInvalidPrefixDetails } from '@sp/sync-core';
|
||||
import { toSyncLogError } from '@sp/sync-core';
|
||||
import {
|
||||
AdditionalLogErrorBase as PackageAdditionalLogErrorBase,
|
||||
extractErrorMessage as packageExtractErrorMessage,
|
||||
} from '@sp/sync-providers/errors';
|
||||
import { FILE_BASED_SYNC_CONSTANTS } from '../../sync-providers/file-based/file-based-sync.types';
|
||||
import { OP_LOG_SYNC_LOGGER } from '../sync-logger.adapter';
|
||||
|
||||
// Re-export provider-shared error classes from @sp/sync-providers.
|
||||
// Single class definition per error is critical for `instanceof` checks
|
||||
|
|
@ -46,20 +44,6 @@ const getValidationErrors = (
|
|||
return undefined;
|
||||
};
|
||||
|
||||
const getValidationErrorPathSummary = (
|
||||
validationResult?: IValidation<unknown>,
|
||||
): string | undefined => {
|
||||
const errors = getValidationErrors(validationResult);
|
||||
if (!errors) return undefined;
|
||||
|
||||
const pathSummary = errors
|
||||
.slice(0, 3)
|
||||
.map((error) => error.path)
|
||||
.filter(Boolean)
|
||||
.join(', ');
|
||||
return pathSummary || undefined;
|
||||
};
|
||||
|
||||
// AdditionalLogErrorBase is provided by @sp/sync-providers (without the
|
||||
// previous constructor-time logging side effect). The remaining app-only
|
||||
// errors below extend it; they MUST log at the catch site via
|
||||
|
|
@ -73,16 +57,6 @@ export class ImpossibleError extends Error {
|
|||
override name = ' ImpossibleError';
|
||||
}
|
||||
|
||||
// --------------APP-SIDE-ONLY API ERRORS--------------
|
||||
|
||||
export class NoEtagAPIError extends AdditionalLogErrorBase {
|
||||
override name = ' NoEtagAPIError';
|
||||
}
|
||||
|
||||
export class FileExistsAPIError extends Error {
|
||||
override name = ' FileExistsAPIError';
|
||||
}
|
||||
|
||||
// --------------OTHER SYNC ERRORS--------------
|
||||
export class NoSyncProviderSetError extends Error {
|
||||
override name = 'NoSyncProviderSetError';
|
||||
|
|
@ -127,43 +101,10 @@ export class LockAcquisitionTimeoutError extends Error {
|
|||
}
|
||||
}
|
||||
|
||||
export class RevMismatchForModelError extends AdditionalLogErrorBase<string> {
|
||||
override name = 'RevMismatchForModelError';
|
||||
}
|
||||
|
||||
export class UnknownSyncStateError extends Error {
|
||||
override name = 'UnknownSyncStateError';
|
||||
}
|
||||
|
||||
export class SyncInvalidTimeValuesError extends AdditionalLogErrorBase {
|
||||
override name = 'SyncInvalidTimeValuesError';
|
||||
}
|
||||
|
||||
export class RevMapModelMismatchErrorOnDownload extends AdditionalLogErrorBase {
|
||||
override name = 'RevMapModelMismatchErrorOnDownload';
|
||||
}
|
||||
|
||||
export class RevMapModelMismatchErrorOnUpload extends AdditionalLogErrorBase {
|
||||
override name = 'RevMapModelMismatchErrorOnUpload';
|
||||
}
|
||||
|
||||
export class NoRemoteModelFile extends AdditionalLogErrorBase<string> {
|
||||
override name = 'NoRemoteModelFile';
|
||||
}
|
||||
|
||||
export class NoRemoteMetaFile extends Error {
|
||||
override name = 'NoRemoteMetaFile';
|
||||
}
|
||||
|
||||
// --------------LOCKFILE ERRORS--------------
|
||||
export class LockPresentError extends Error {
|
||||
override name = 'LockPresentError';
|
||||
}
|
||||
|
||||
export class LockFromLocalClientPresentError extends Error {
|
||||
override name = 'LockFromLocalClientPresentError';
|
||||
}
|
||||
|
||||
// -----ENCRYPTION & COMPRESSION----
|
||||
export class DecryptNoPasswordError extends AdditionalLogErrorBase {
|
||||
override name = 'DecryptNoPasswordError';
|
||||
|
|
@ -238,12 +179,6 @@ export class JsonParseError extends Error {
|
|||
const end = Math.min(dataStr.length, position + 50);
|
||||
this.dataSample = `...${dataStr.substring(start, end)}...`;
|
||||
}
|
||||
|
||||
OP_LOG_SYNC_LOGGER.err('JsonParseError', toSyncLogError(originalError), {
|
||||
position: this.position,
|
||||
dataLength: dataStr?.length,
|
||||
hasDataSample: this.dataSample !== undefined,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -260,14 +195,6 @@ export class InvalidMetaError extends AdditionalLogErrorBase {
|
|||
override name = 'InvalidMetaError';
|
||||
}
|
||||
|
||||
export class MetaNotReadyError extends AdditionalLogErrorBase {
|
||||
override name = 'MetaNotReadyError';
|
||||
}
|
||||
|
||||
export class InvalidRevMapError extends AdditionalLogErrorBase {
|
||||
override name = 'InvalidRevMapError';
|
||||
}
|
||||
|
||||
export class ModelIdWithoutCtrlError extends AdditionalLogErrorBase {
|
||||
override name = 'ModelIdWithoutCtrlError';
|
||||
}
|
||||
|
|
@ -303,15 +230,6 @@ export class ModelValidationError extends Error {
|
|||
e?: unknown;
|
||||
}) {
|
||||
super('ModelValidationError');
|
||||
OP_LOG_SYNC_LOGGER.log('ModelValidationError', {
|
||||
id: params.id,
|
||||
hasValidationResult: params.validationResult !== undefined,
|
||||
validationErrorCount: getValidationErrors(params.validationResult)?.length,
|
||||
validationPathSummary: getValidationErrorPathSummary(params.validationResult),
|
||||
hasAdditionalError: params.e !== undefined,
|
||||
additionalErrorName:
|
||||
params.e !== undefined ? toSyncLogError(params.e).name : undefined,
|
||||
});
|
||||
|
||||
if (params.validationResult) {
|
||||
try {
|
||||
|
|
@ -320,12 +238,8 @@ export class ModelValidationError extends Error {
|
|||
const str = JSON.stringify(errors);
|
||||
this.additionalLog = `Model: ${params.id}, Errors: ${str.substring(0, 400)}`;
|
||||
}
|
||||
} catch (e) {
|
||||
OP_LOG_SYNC_LOGGER.err(
|
||||
'Error stringifying validation errors',
|
||||
toSyncLogError(e),
|
||||
{ id: params.id },
|
||||
);
|
||||
} catch {
|
||||
// Ignore stringification errors
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -338,10 +252,6 @@ export class DataValidationFailedError extends Error {
|
|||
constructor(validationResult: IValidation<unknown>) {
|
||||
const errorSummary = DataValidationFailedError._buildErrorSummary(validationResult);
|
||||
super(errorSummary);
|
||||
OP_LOG_SYNC_LOGGER.log('DataValidationFailedError', {
|
||||
validationErrorCount: getValidationErrors(validationResult)?.length,
|
||||
validationPathSummary: getValidationErrorPathSummary(validationResult),
|
||||
});
|
||||
|
||||
try {
|
||||
const errors = getValidationErrors(validationResult);
|
||||
|
|
@ -349,8 +259,8 @@ export class DataValidationFailedError extends Error {
|
|||
const str = JSON.stringify(errors);
|
||||
this.additionalLog = str.substring(0, 400);
|
||||
}
|
||||
} catch (e) {
|
||||
OP_LOG_SYNC_LOGGER.err('Failed to stringify validation errors', toSyncLogError(e));
|
||||
} catch {
|
||||
// Ignore stringification errors
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -45,11 +45,7 @@ export {
|
|||
SyncAlreadyInProgressError,
|
||||
LockAcquisitionTimeoutError,
|
||||
CanNotMigrateMajorDownError,
|
||||
LockPresentError,
|
||||
NoRemoteModelFile,
|
||||
PotentialCorsError,
|
||||
RevMismatchForModelError,
|
||||
SyncInvalidTimeValuesError,
|
||||
} from './core/errors/sync-errors';
|
||||
|
||||
// Provider interfaces
|
||||
|
|
|
|||
|
|
@ -1,9 +1,8 @@
|
|||
import { Injectable } from '@angular/core';
|
||||
|
||||
/**
|
||||
* In-tab mutual-exclusion guard for the four top-level sync entry points:
|
||||
* In-tab mutual-exclusion guard for the three top-level sync entry points:
|
||||
* - `SyncWrapperService.sync()` (periodic / user-triggered full sync)
|
||||
* - `SyncWrapperService._forceDownload()` (sync-error dialog recovery)
|
||||
* - `ImmediateUploadService._performUpload()` (side channel)
|
||||
* - `WsTriggeredDownloadService._downloadOps()` (side channel)
|
||||
*
|
||||
|
|
|
|||
|
|
@ -7,7 +7,6 @@ import { SyncLog } from '../../core/log';
|
|||
*
|
||||
* A "sync session" is a single top-level sync operation:
|
||||
* - `SyncWrapperService._sync()`
|
||||
* - `SyncWrapperService._forceDownload()`
|
||||
* - `WsTriggeredDownloadService._downloadOps()`
|
||||
* - `ImmediateUploadService._performUpload()`
|
||||
*
|
||||
|
|
|
|||
|
|
@ -1274,20 +1274,6 @@ const T = {
|
|||
OK: 'F.SYNC.D_FRESH_CLIENT_CONFIRM.OK',
|
||||
TITLE: 'F.SYNC.D_FRESH_CLIENT_CONFIRM.TITLE',
|
||||
},
|
||||
D_INCOMPLETE_SYNC: {
|
||||
BTN_CLOSE_APP: 'F.SYNC.D_INCOMPLETE_SYNC.BTN_CLOSE_APP',
|
||||
BTN_DOWNLOAD_BACKUP: 'F.SYNC.D_INCOMPLETE_SYNC.BTN_DOWNLOAD_BACKUP',
|
||||
BTN_FORCE_DOWNLOAD: 'F.SYNC.D_INCOMPLETE_SYNC.BTN_FORCE_DOWNLOAD',
|
||||
BTN_FORCE_UPLOAD: 'F.SYNC.D_INCOMPLETE_SYNC.BTN_FORCE_UPLOAD',
|
||||
INCOHERENT_P1: 'F.SYNC.D_INCOMPLETE_SYNC.INCOHERENT_P1',
|
||||
INCOHERENT_P2: 'F.SYNC.D_INCOMPLETE_SYNC.INCOHERENT_P2',
|
||||
P1: 'F.SYNC.D_INCOMPLETE_SYNC.P1',
|
||||
P2: 'F.SYNC.D_INCOMPLETE_SYNC.P2',
|
||||
T3: 'F.SYNC.D_INCOMPLETE_SYNC.T3',
|
||||
T4: 'F.SYNC.D_INCOMPLETE_SYNC.T4',
|
||||
T5: 'F.SYNC.D_INCOMPLETE_SYNC.T5',
|
||||
T6: 'F.SYNC.D_INCOMPLETE_SYNC.T6',
|
||||
},
|
||||
D_INITIAL_CFG: {
|
||||
SAVE_AND_ENABLE: 'F.SYNC.D_INITIAL_CFG.SAVE_AND_ENABLE',
|
||||
TITLE: 'F.SYNC.D_INITIAL_CFG.TITLE',
|
||||
|
|
@ -1556,7 +1542,6 @@ const T = {
|
|||
DATA_REPAIRED: 'F.SYNC.S.DATA_REPAIRED',
|
||||
DECRYPTION_FAILED: 'F.SYNC.S.DECRYPTION_FAILED',
|
||||
DEFERRED_ACTION_FAILED: 'F.SYNC.S.DEFERRED_ACTION_FAILED',
|
||||
DIALOG_RESULT_ERROR: 'F.SYNC.S.DIALOG_RESULT_ERROR',
|
||||
DISABLE_ENCRYPTION_FAILED: 'F.SYNC.S.DISABLE_ENCRYPTION_FAILED',
|
||||
ENABLE_ENCRYPTION_FAILED: 'F.SYNC.S.ENABLE_ENCRYPTION_FAILED',
|
||||
ENCRYPTION_DISABLED_ON_OTHER_DEVICE:
|
||||
|
|
@ -1564,7 +1549,6 @@ const T = {
|
|||
ENCRYPTION_PASSWORD_REQUIRED: 'F.SYNC.S.ENCRYPTION_PASSWORD_REQUIRED',
|
||||
ENCRYPTION_REQUIRED_FOR_SUPERSYNC: 'F.SYNC.S.ENCRYPTION_REQUIRED_FOR_SUPERSYNC',
|
||||
ERROR_CORS: 'F.SYNC.S.ERROR_CORS',
|
||||
ERROR_DATA_IS_CURRENTLY_WRITTEN: 'F.SYNC.S.ERROR_DATA_IS_CURRENTLY_WRITTEN',
|
||||
ERROR_PAYLOAD_TOO_LARGE: 'F.SYNC.S.ERROR_PAYLOAD_TOO_LARGE',
|
||||
ERROR_PERMISSION: 'F.SYNC.S.ERROR_PERMISSION',
|
||||
ERROR_PERMISSION_FLATPAK: 'F.SYNC.S.ERROR_PERMISSION_FLATPAK',
|
||||
|
|
|
|||
|
|
@ -1250,20 +1250,6 @@
|
|||
"OK": "Download Remote Data",
|
||||
"TITLE": "Initial Sync"
|
||||
},
|
||||
"D_INCOMPLETE_SYNC": {
|
||||
"BTN_CLOSE_APP": "Close App",
|
||||
"BTN_DOWNLOAD_BACKUP": "Download Local Backup",
|
||||
"BTN_FORCE_DOWNLOAD": "Force Download Remote",
|
||||
"BTN_FORCE_UPLOAD": "Force Upload Local",
|
||||
"INCOHERENT_P1": "The date of your remote data is from the future",
|
||||
"INCOHERENT_P2": "You should check the time configured on your systems!",
|
||||
"P1": "The remote sync data is incoherent!",
|
||||
"P2": "Affected model:",
|
||||
"T3": "You have 2 Options:",
|
||||
"T4": "1. Go to your other device and complete the sync there.",
|
||||
"T5": "2. Or you can overwrite the remote data with your local one. All remote changes will\n be lost!",
|
||||
"T6": "Creating a backup of the data you overwrite is recommended!!!"
|
||||
},
|
||||
"D_INITIAL_CFG": {
|
||||
"SAVE_AND_ENABLE": "Save & Enable Sync",
|
||||
"TITLE": "Configure Sync"
|
||||
|
|
@ -1511,14 +1497,12 @@
|
|||
"DATA_REPAIRED": "Sync cleanup: {{count}} reference(s) resolved",
|
||||
"DECRYPTION_FAILED": "Failed to decrypt synced data. Please check your encryption password.",
|
||||
"DEFERRED_ACTION_FAILED": "Some changes made during sync could not be saved. Please reload to recover.",
|
||||
"DIALOG_RESULT_ERROR": "An error occurred while processing the sync dialog. Please try again.",
|
||||
"DISABLE_ENCRYPTION_FAILED": "Failed to disable encryption: {{message}}",
|
||||
"ENABLE_ENCRYPTION_FAILED": "Failed to enable encryption: {{message}}",
|
||||
"ENCRYPTION_DISABLED_ON_OTHER_DEVICE": "Encryption was disabled on another device. Your sync settings have been updated.",
|
||||
"ENCRYPTION_PASSWORD_REQUIRED": "Encrypted data received but no encryption password is configured. Please set your encryption password in sync settings.",
|
||||
"ENCRYPTION_REQUIRED_FOR_SUPERSYNC": "Encryption is required for SuperSync. Please set an encryption password to continue syncing.",
|
||||
"ERROR_CORS": "WebDAV Sync Error: Network request failed.\n\nThis may be a CORS issue. Please ensure:\n• Your WebDAV server allows Cross-Origin requests\n• The server URL is correct and accessible\n• You have a working internet connection",
|
||||
"ERROR_DATA_IS_CURRENTLY_WRITTEN": "Remote Data is currently being written",
|
||||
"ERROR_PAYLOAD_TOO_LARGE": "Sync Error: Your data is too large to upload.\n\nThis happens when you have many archived tasks. Your data was NOT synced!\n\nPlease contact support for assistance.",
|
||||
"ERROR_PERMISSION": "File access denied. Please check your filesystem permissions.",
|
||||
"ERROR_PERMISSION_FLATPAK": "File access denied. Grant filesystem permission via Flatseal or use a path inside ~/.var/app/",
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue