Skip to content

Commit 0605c70

Browse files
committed
refactor
1 parent 1fee720 commit 0605c70

25 files changed

Lines changed: 512 additions & 301 deletions

docs/TODO.md

Lines changed: 27 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,33 @@
1919
- [x] Tombstones for deletion tracking
2020
- [x] Per-file granularity (not repo-level overwrite)
2121
- [x] Pull-then-push flow (when remote is newer, pull first then push local changes)
22+
- [x] Consolidate logger files - unified logging system with ports & adapters pattern
23+
24+
## In Progress: Code Quality & Refactoring
25+
26+
### High Priority
27+
- [x] **Consolidate encoding utilities** - `packer.ts` & `item-packer.ts` have identical `calculateChecksum()`, `uint8ArrayToBase64()`, `base64ToUint8Array()`
28+
- Created `src/shared/encoding-utils.ts` with shared encoding functions
29+
- [x] **Extract error handling pattern** - pattern `error instanceof Error ? error.message : String(error)` repeated in 5+ files
30+
- Created `src/shared/error-utils.ts` with `getErrorMessage()`, `getErrorStack()`, `toError()`, `isError()`
31+
- [x] **Consolidate sync result processing** - `background-sync.ts` & `sync-handler.ts` have duplicate write/persist logic
32+
- Created `src/shared/sync-result-handler.ts` with `writePulledData()`, `persistLocalState()`, `processSyncResult()`
33+
34+
### Medium Priority
35+
- [x] **Unify error classes** - 6 error classes (`RepoApiError`, `SyncError`, `EncryptionError`, `PackerError`, `ItemPackerError`, `MergeError`) unified
36+
- Created `src/shared/errors.ts` with `AppError` base class and all domain error classes
37+
- [x] **Centralize retry configuration** - retry constants scattered across `fetch.ts`, `constants.ts`, `retry.ts`
38+
- Created `src/shared/retry-config.ts` with `CONFLICT_RETRY` and `API_RETRY` config objects, plus `calculateBackoff()` and `sleep()` utilities
39+
- [x] **Extract plugin state validation** - `if (!state.config || !state.engine)` pattern in 3+ files
40+
- Created `src/plugin/validation.ts` with type guards: `hasConfig()`, `isPluginReady()`, `isContinuousSyncReady()`, `isFullyInitialized()`
41+
- [x] **Move CryptoOptions type** - currently in `operations/types.ts`, should be in `src/types/crypto.ts`
42+
- Created `src/types/crypto.ts` with `CryptoOptions` and `PassphraseOption` types, re-exported from `operations/types.ts` for backward compatibility
43+
44+
### Low Priority
45+
- [x] **Review helper duplication** - `engine/helpers.ts` vs `operations/helpers.ts` may have overlapping functionality
46+
- Reviewed: No duplication found. `engine/helpers.ts` handles orchestration (checksums, pull/push options), `operations/helpers.ts` handles low-level operations (tombstones, manifest, context). Complementary, not duplicative.
47+
- [x] **Consider API client base class** - `http-client.ts` and `graphql-client.ts` have similar initialization patterns
48+
- Reviewed: Not worth abstracting. Only ~15 lines similar (config fields). Different APIs (REST vs GraphQL), different methods, different URL patterns. Base class would add complexity without benefit.
2249

2350
## In Progress: Safety & Observability
2451

@@ -40,21 +67,3 @@
4067
- [ ] Compression improvements for large messages
4168
- [ ] Parallel category processing during sync
4269

43-
## Design Notes
44-
45-
### Timestamp-Based Sync Trade-offs (Research-Backed)
46-
47-
- **Clock skew risk**: NTP drift 1-50ms (cloud), 100-500ms (geo-distributed)
48-
- Source: "Clock Skew Conflict in Distributed Systems" (2026)
49-
- Mitigation: Modern OS auto-sync via NTP (atomic clock: 1s in 3M years)
50-
- Impact: Very low - machines rarely drift years apart
51-
- Real-world: CockroachDB uses 500ms max offset tolerance successfully
52-
- **Concurrent edit data loss**: Last-write-wins on same file from 2 machines
53-
- Source: "Beyond Timestamps: Conflict Resolution" (2025)
54-
- Likelihood: Very low for CLI tool (one session at a time)
55-
- Impact: One edit lost, logged for manual recovery
56-
- Mitigation: Sessions/messages are mostly append-only (different files)
57-
- **Config overwrites**: Settings changes on both machines = last wins
58-
- Mitigation: Show warning, allow manual merge via flags
59-
- Frequency: Low - settings changed infrequently
60-
- Precedent: VS Code Settings Sync (4M+ users) uses simple last-write-wins

src/crypto/encrypt.ts

Lines changed: 3 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77

88
import { createCipheriv, createDecipheriv, pbkdf2Sync, randomBytes, createHash } from 'node:crypto';
99
import type { EncryptedData } from '../types/index.js';
10+
import { EncryptionError } from '../shared/index.js';
1011

1112
/** PBKDF2 iterations for key derivation */
1213
const PBKDF2_ITERATIONS = 100000;
@@ -124,12 +125,5 @@ export function isEncryptedData(data: unknown): data is EncryptedData {
124125
);
125126
}
126127

127-
/**
128-
* Error thrown when encryption/decryption fails.
129-
*/
130-
export class EncryptionError extends Error {
131-
constructor(message: string) {
132-
super(message);
133-
this.name = 'EncryptionError';
134-
}
135-
}
128+
// Re-export for backward compatibility
129+
export { EncryptionError } from '../shared/index.js';

src/plugin/background-sync.ts

Lines changed: 12 additions & 44 deletions
Original file line numberDiff line numberDiff line change
@@ -5,56 +5,24 @@
55
import type { PathConfig } from '../types/paths.js';
66
import type { SyncCategory } from '../types/index.js';
77
import type { SyncResult } from '../types/sync.js';
8-
import type { CategoryData } from '../sync/operations/types.js';
9-
import {
10-
loadLocalData,
11-
saveLocalState,
12-
writeLocalData,
13-
deleteTombstonedItems,
14-
} from '../data/index.js';
8+
import { loadLocalData } from '../data/index.js';
159
import { FileWatcher } from '../sync/watcher/index.js';
16-
import { syncLog, log } from '../logging/index.js';
10+
import { log } from '../logging/index.js';
1711
import { getPluginState } from './state-manager.js';
12+
import { isContinuousSyncReady } from './validation.js';
13+
import { getErrorMessage, writePulledData, persistLocalState } from '../shared/index.js';
1814

1915
/** Active file watcher instance */
2016
let activeWatcher: FileWatcher | null = null;
2117

2218
/** Active interval timer for periodic sync */
2319
let syncInterval: NodeJS.Timeout | null = null;
2420

25-
/** Write pulled data to disk after a successful pull/merge */
26-
export async function writePulledData(pathConfig: PathConfig, result: SyncResult): Promise<void> {
27-
if (result.action !== 'pulled' && result.action !== 'merged') {
28-
return;
29-
}
30-
31-
if (result.pulledData) {
32-
const data = result.pulledData as CategoryData[];
33-
syncLog(`[WRITE] Writing ${String(data.length)} categories to disk`);
34-
await writeLocalData(pathConfig, data);
35-
syncLog(`[WRITE] Finished writing to disk`);
36-
}
37-
38-
if (result.tombstonedItems) {
39-
await deleteTombstonedItems(pathConfig, result.tombstonedItems);
40-
}
41-
}
42-
43-
/** Persist engine's local state to disk after successful sync */
44-
export async function persistLocalState(pathConfig: PathConfig): Promise<void> {
45-
const state = getPluginState();
46-
const newState = state.engine?.getLocalState();
47-
if (newState) {
48-
await saveLocalState(pathConfig, newState);
49-
state.localState = newState;
50-
}
51-
}
52-
5321
/** Start file watcher for continuous sync */
5422
export function startFileWatcher(pathConfig: PathConfig): void {
5523
const state = getPluginState();
5624

57-
if (!state.config?.continuousSync || !state.engine) {
25+
if (!isContinuousSyncReady(state)) {
5826
return;
5927
}
6028

@@ -81,24 +49,22 @@ export function startFileWatcher(pathConfig: PathConfig): void {
8149
await persistLocalState(pathConfig);
8250
}
8351
} catch (error) {
84-
const errMsg = error instanceof Error ? error.message : String(error);
85-
log(`WARNING: File watcher sync failed: ${errMsg}`);
52+
log(`WARNING: File watcher sync failed: ${getErrorMessage(error)}`);
8653
}
8754
},
8855
});
8956
void activeWatcher.start();
9057
log('File watcher started');
9158
} catch (error) {
92-
const errMsg = error instanceof Error ? error.message : String(error);
93-
log(`WARNING: Failed to start file watcher: ${errMsg}`);
59+
log(`WARNING: Failed to start file watcher: ${getErrorMessage(error)}`);
9460
}
9561
}
9662

9763
/** Start interval-based sync */
9864
export function startIntervalSync(pathConfig: PathConfig): void {
9965
const state = getPluginState();
10066

101-
if (!state.config?.continuousSync || !state.engine) {
67+
if (!isContinuousSyncReady(state)) {
10268
return;
10369
}
10470

@@ -117,8 +83,7 @@ export function startIntervalSync(pathConfig: PathConfig): void {
11783
}
11884
logIntervalResult(result);
11985
} catch (error) {
120-
const errMsg = error instanceof Error ? error.message : String(error);
121-
log(`WARNING: Interval sync failed: ${errMsg}`);
86+
log(`WARNING: Interval sync failed: ${getErrorMessage(error)}`);
12287
}
12388
})();
12489
}, intervalMs);
@@ -151,3 +116,6 @@ export function stopBackgroundSync(): void {
151116
log('Interval sync stopped');
152117
}
153118
}
119+
120+
// Re-export for backward compatibility
121+
export { writePulledData, persistLocalState } from '../shared/index.js';

src/plugin/plugin.ts

Lines changed: 11 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ import {
1414
updateConfig,
1515
initializeEngine,
1616
} from './state-manager.js';
17+
import { hasConfig, isFullyInitialized } from './validation.js';
1718
import { getTokenSource, loadLocalData } from '../data/index.js';
1819
import { RepoStorageBackend } from '../storage/index.js';
1920
import type { PluginState } from './types.js';
@@ -26,13 +27,14 @@ import {
2627
startIntervalSync,
2728
stopBackgroundSync,
2829
} from './background-sync.js';
30+
import { getErrorMessage, getErrorStack } from '../shared/index.js';
2931

3032
/** Default repo name for sync storage */
3133
const DEFAULT_REPO_NAME = '.opencode-sync';
3234

3335
/** Validate configuration and log status. Returns true if valid. */
3436
function validateAndLogConfig(state: PluginState): boolean {
35-
if (!state.config) {
37+
if (!hasConfig(state)) {
3638
log('WARNING: No configuration found');
3739
logSetupInstructions();
3840
log('Plugin loaded but sync is disabled');
@@ -102,8 +104,7 @@ async function ensureStorageExists(pathConfig: PathConfig): Promise<void> {
102104
await updateConfig(pathConfig, { repoOwner: owner, repoName });
103105
initializeEngine();
104106
} catch (error) {
105-
const errMsg = error instanceof Error ? error.message : String(error);
106-
log(`ERROR: Failed to setup storage: ${errMsg}`);
107+
log(`ERROR: Failed to setup storage: ${getErrorMessage(error)}`);
107108
throw error;
108109
}
109110
}
@@ -112,12 +113,12 @@ async function ensureStorageExists(pathConfig: PathConfig): Promise<void> {
112113
function performInitialSync(pathConfig: PathConfig): void {
113114
const state = getPluginState();
114115

115-
if (!state.isInitialized || !state.engine) {
116+
if (!isFullyInitialized(state)) {
116117
log('Skipping initial sync - engine not initialized');
117118
return;
118119
}
119120

120-
if (!state.config?.autoSyncOnStartup) {
121+
if (!state.config.autoSyncOnStartup) {
121122
log('Auto-sync on startup disabled');
122123
return;
123124
}
@@ -144,9 +145,9 @@ function performInitialSync(pathConfig: PathConfig): void {
144145
log(`Sync completed in ${String(dur)}ms: ${result.message}`);
145146
}
146147
} catch (error) {
147-
const errMsg = error instanceof Error ? error.message : String(error);
148-
log(`WARNING: Initial sync failed: ${errMsg}`);
149-
if (error instanceof Error && error.stack) log(`Stack: ${error.stack}`);
148+
log(`WARNING: Initial sync failed: ${getErrorMessage(error)}`);
149+
const stack = getErrorStack(error);
150+
if (stack) log(`Stack: ${stack}`);
150151
}
151152
})();
152153
}
@@ -169,9 +170,8 @@ export const OpencodeSyncPlugin = async (_ctx: unknown): Promise<Record<string,
169170

170171
return { cleanup: stopBackgroundSync };
171172
} catch (error) {
172-
const errMsg = error instanceof Error ? error.message : String(error);
173-
const stack = error instanceof Error ? error.stack : undefined;
174-
log(`ERROR: Plugin initialization failed: ${errMsg}`);
173+
log(`ERROR: Plugin initialization failed: ${getErrorMessage(error)}`);
174+
const stack = getErrorStack(error);
175175
if (stack) {
176176
console.error(stack);
177177
}

src/plugin/state-manager.ts

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ import { createFileWatcher } from '../sync/watcher/index.js';
1515
import { loadConfig, saveConfig, loadLocalState, generateMachineId } from '../data/index.js';
1616
import type { PluginState } from './types.js';
1717
import { createInitialState } from './types.js';
18+
import { hasConfig } from './validation.js';
1819

1920
const state: PluginState = createInitialState();
2021

@@ -38,7 +39,7 @@ export function getPluginState(): PluginState {
3839
* Initialize the sync engine with current configuration.
3940
*/
4041
export function initializeEngine(): void {
41-
if (!state.config) return;
42+
if (!hasConfig(state)) return;
4243
if (!state.config.repoOwner || !state.config.repoName) return;
4344

4445
const backendConfig: RepoClientConfig = {
@@ -86,7 +87,7 @@ export async function updateConfig(
8687
return;
8788
}
8889

89-
if (!state.config) {
90+
if (!hasConfig(state)) {
9091
state.config = {
9192
...DEFAULT_CONFIG,
9293
token: updates.token ?? '',
@@ -106,7 +107,7 @@ export async function updateConfig(
106107
*/
107108
export function setPassphrase(passphrase: string): void {
108109
state.passphrase = passphrase;
109-
if (state.config) {
110+
if (hasConfig(state)) {
110111
initializeEngine();
111112
}
112113
}
@@ -118,7 +119,7 @@ export async function startWatcher(
118119
pathConfig: PathConfig,
119120
onSync: () => Promise<void>
120121
): Promise<void> {
121-
if (!state.config?.continuousSync) return;
122+
if (!hasConfig(state) || !state.config.continuousSync) return;
122123

123124
state.watcher = createFileWatcher(
124125
pathConfig,

src/plugin/sync-handler.ts

Lines changed: 8 additions & 36 deletions
Original file line numberDiff line numberDiff line change
@@ -6,15 +6,10 @@
66

77
import type { PathConfig } from '../types/paths.js';
88
import type { SyncResult } from '../types/index.js';
9-
import type { CategoryData } from '../sync/operations/types.js';
10-
import {
11-
loadLocalData,
12-
saveLocalState,
13-
writeLocalData,
14-
deleteTombstonedItems,
15-
} from '../data/index.js';
9+
import { loadLocalData } from '../data/index.js';
1610
import { getPluginState } from './state-manager.js';
17-
import { syncLog } from '../logging/index.js';
11+
import { isPluginReady } from './validation.js';
12+
import { getErrorMessage, writePulledData, persistLocalState } from '../shared/index.js';
1813

1914
type LogLevel = 'error' | 'info' | 'debug' | 'warn';
2015

@@ -32,7 +27,7 @@ interface LogClient {
3227
export async function performSync(pathConfig: PathConfig, client: LogClient): Promise<void> {
3328
const state = getPluginState();
3429

35-
if (!state.config || !state.engine) {
30+
if (!isPluginReady(state)) {
3631
return;
3732
}
3833

@@ -46,7 +41,7 @@ export async function performSync(pathConfig: PathConfig, client: LogClient): Pr
4641
await logSyncError(client, result.message);
4742
}
4843
} catch (error) {
49-
await logSyncError(client, error instanceof Error ? error.message : 'Unknown');
44+
await logSyncError(client, getErrorMessage(error));
5045
}
5146
}
5247

@@ -58,32 +53,9 @@ async function handleSyncSuccess(
5853
client: LogClient,
5954
result: SyncResult
6055
): Promise<void> {
61-
const state = getPluginState();
62-
const newState = state.engine?.getLocalState();
63-
64-
// Write pulled data to local filesystem
65-
// This includes data from pull, merge, AND remote items fetched during push
66-
if (result.action === 'pulled' || result.action === 'merged' || result.action === 'pushed') {
67-
syncLog(
68-
`[WRITE] Action: ${result.action}, pulledData: ${result.pulledData ? 'present' : 'missing'}`
69-
);
70-
if (result.pulledData) {
71-
const data = result.pulledData as CategoryData[];
72-
syncLog(`[WRITE] Writing ${String(data.length)} categories to disk`);
73-
await writeLocalData(pathConfig, data);
74-
syncLog(`[WRITE] Finished writing to disk`);
75-
}
76-
if (result.tombstonedItems) {
77-
await deleteTombstonedItems(pathConfig, result.tombstonedItems);
78-
}
79-
} else {
80-
syncLog(`[WRITE] Skipping write - action: ${result.action}`);
81-
}
82-
83-
if (newState) {
84-
await saveLocalState(pathConfig, newState);
85-
state.localState = newState;
86-
}
56+
// Write pulled data and persist state using shared utilities
57+
await writePulledData(pathConfig, result);
58+
await persistLocalState(pathConfig);
8759

8860
await client.app.log({
8961
body: {

0 commit comments

Comments
 (0)