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:
Harbinger 2026-06-20 19:36:56 +08:00 committed by GitHub
parent b8d1dbe261
commit 8171bb05d0
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
15 changed files with 38 additions and 507 deletions

View file

@ -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.

View file

@ -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) |

View file

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

View file

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

View file

@ -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>

View file

@ -1,7 +0,0 @@
:host > * {
max-width: 500px;
}
:host code {
font-size: 10px;
}

View file

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

View file

@ -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.

View file

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

View file

@ -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
}
}

View file

@ -45,11 +45,7 @@ export {
SyncAlreadyInProgressError,
LockAcquisitionTimeoutError,
CanNotMigrateMajorDownError,
LockPresentError,
NoRemoteModelFile,
PotentialCorsError,
RevMismatchForModelError,
SyncInvalidTimeValuesError,
} from './core/errors/sync-errors';
// Provider interfaces

View file

@ -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)
*

View file

@ -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()`
*

View file

@ -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',

View file

@ -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/",