Skip to content

Commit 7712586

Browse files
committed
feat(sync): enhance sync functionality with local state persistence and improved data merging
1 parent ad94ec2 commit 7712586

4 files changed

Lines changed: 122 additions & 10 deletions

File tree

docs/CONFIG.md

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -99,6 +99,10 @@ On subsequent runs:
9999
| `fileWatcherDebounceMs` | number | `5000` | Wait after file change before sync |
100100
| `maxDebounceMs` | number | `30000` | Max wait during heavy activity |
101101
| `conflictStrategy` | string | `auto-merge` | How to resolve conflicts |
102+
| `advisoryLockTimeoutSeconds` | number | `30` | Lock timeout for concurrent sync prevention |
103+
| `maxRetryAttempts` | number | `3` | Max retries on transient failures |
104+
| `retryDelayMs` | number | `1000` | Delay between retry attempts |
105+
| `tombstoneGraceDays` | number | `30` | Days before deleted item markers expire |
102106

103107
## Sync Categories
104108

@@ -151,6 +155,12 @@ All categories are enabled by default. Disable `messages` if you have very large
151155
"projects": true,
152156
"todos": true
153157
},
154-
"conflictStrategy": "auto-merge"
158+
"conflictStrategy": "auto-merge",
159+
"advisoryLockTimeoutSeconds": 30,
160+
"maxRetryAttempts": 3,
161+
"retryDelayMs": 1000,
162+
"tombstoneGraceDays": 30
155163
}
156164
```
165+
166+
Note: `machineId`, `keySalt`, and `passphraseHash` are auto-generated and should not be manually edited.

src/plugin/plugin.ts

Lines changed: 31 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@ import {
1414
updateConfig,
1515
initializeEngine,
1616
} from './state-manager.js';
17-
import { getTokenSource, loadLocalData } from '../data/index.js';
17+
import { getTokenSource, loadLocalData, saveLocalState } from '../data/index.js';
1818
import { RepoStorageBackend } from '../storage/index.js';
1919
import { FileWatcher } from '../sync/watcher/index.js';
2020
import type { PluginState } from './types.js';
@@ -187,6 +187,16 @@ async function ensureStorageExists(pathConfig: PathConfig): Promise<void> {
187187
}
188188
}
189189

190+
/** Persist engine's local state to disk after successful sync */
191+
async function persistLocalState(pathConfig: PathConfig): Promise<void> {
192+
const state = getPluginState();
193+
const newState = state.engine?.getLocalState();
194+
if (newState) {
195+
await saveLocalState(pathConfig, newState);
196+
state.localState = newState;
197+
}
198+
}
199+
190200
/** Perform initial sync on plugin startup (non-blocking) */
191201
function performInitialSync(pathConfig: PathConfig): void {
192202
const state = getPluginState();
@@ -211,6 +221,7 @@ function performInitialSync(pathConfig: PathConfig): void {
211221
const result = await engine.sync(categories);
212222

213223
if (result.success && result.action !== 'error') {
224+
await persistLocalState(pathConfig);
214225
log(`Initial sync complete: ${result.message}`);
215226
} else {
216227
log(`Sync completed: ${result.message}`);
@@ -248,7 +259,10 @@ function startFileWatcher(pathConfig: PathConfig): void {
248259
onEvent: async () => {
249260
try {
250261
const { categories } = await loadLocalData(pathConfig, config.sync);
251-
await engine.sync(categories);
262+
const result = await engine.sync(categories);
263+
if (result.success) {
264+
await persistLocalState(pathConfig);
265+
}
252266
} catch (error) {
253267
const errMsg = error instanceof Error ? error.message : String(error);
254268
log(`WARNING: File watcher sync failed: ${errMsg}`);
@@ -279,7 +293,21 @@ function startIntervalSync(pathConfig: PathConfig): void {
279293
void (async () => {
280294
try {
281295
const { categories } = await loadLocalData(pathConfig, config.sync);
282-
await engine.sync(categories);
296+
const result = await engine.sync(categories);
297+
// Persist state after successful sync
298+
if (result.success) {
299+
await persistLocalState(pathConfig);
300+
}
301+
// Log interval sync results (both success and no-change)
302+
if (result.action === 'pushed') {
303+
log(`Interval sync: ${result.message}`);
304+
} else if (result.action === 'pulled') {
305+
log(`Interval sync: ${result.message}`);
306+
} else if (result.action === 'no-change') {
307+
// Don't log no-change to keep logs clean
308+
} else if (result.action === 'error') {
309+
log(`WARNING: Interval sync error: ${result.message}`);
310+
}
283311
} catch (error) {
284312
const errMsg = error instanceof Error ? error.message : String(error);
285313
log(`WARNING: Interval sync failed: ${errMsg}`);

src/sync/engine/state.ts

Lines changed: 70 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -6,9 +6,9 @@
66

77
import { calculateChecksum } from '../packer.js';
88
import type { StorageBackend } from '../../storage/index.js';
9-
import type { Manifest, LocalSyncState } from '../../types/index.js';
9+
import type { Manifest, LocalSyncState, SyncCategory } from '../../types/index.js';
1010
import type { CategoryData, StorageFiles } from '../operations/types.js';
11-
import { isBlobCategoryData } from '../operations/types.js';
11+
import { isBlobCategoryData, isItemCategoryData } from '../operations/types.js';
1212

1313
/**
1414
* Build updated local state after a sync operation.
@@ -23,14 +23,17 @@ export function buildLocalState(
2323
const now = new Date().toISOString();
2424
const checksums: LocalSyncState['categoryChecksums'] = {};
2525
const baseVersions: LocalSyncState['baseVersions'] = {};
26+
const itemChecksums: Partial<Record<SyncCategory, Record<string, string>>> = {};
2627

2728
for (const item of data) {
28-
// Only blob categories have a single data string for checksum/base tracking
2929
if (isBlobCategoryData(item)) {
30+
// Blob categories: track single checksum and base version for three-way merge
3031
checksums[item.category] = calculateChecksum(item.data);
3132
baseVersions[item.category] = item.data;
33+
} else if (isItemCategoryData(item)) {
34+
// Per-item categories: track individual item checksums for deletion detection
35+
itemChecksums[item.category] = item.checksums;
3236
}
33-
// Per-item categories don't need base versions (they use additive merge)
3437
}
3538

3639
return {
@@ -41,6 +44,7 @@ export function buildLocalState(
4144
vectorClock: manifest.vectorClock,
4245
categoryChecksums: checksums,
4346
baseVersions,
47+
itemChecksums,
4448
};
4549
}
4650

@@ -60,6 +64,68 @@ export function isLockedByOther(
6064
return Date.now() - lockTime < timeout;
6165
}
6266

67+
/** Build a lookup map from category array */
68+
function buildCategoryMap(data: CategoryData[]): Map<SyncCategory, CategoryData> {
69+
const map = new Map<SyncCategory, CategoryData>();
70+
for (const item of data) map.set(item.category, item);
71+
return map;
72+
}
73+
74+
/** Merge item category data (local + pulled) */
75+
import type { ItemCategoryData } from '../operations/types.js';
76+
77+
function mergeItemData(local: ItemCategoryData, pulled: ItemCategoryData): CategoryData {
78+
return {
79+
category: pulled.category,
80+
type: 'items',
81+
items: { ...local.items, ...pulled.items },
82+
checksums: { ...local.checksums, ...pulled.checksums },
83+
};
84+
}
85+
86+
/** Process a single pulled category, merging with local if needed */
87+
function processPulledCategory(
88+
pulled: CategoryData,
89+
localByCategory: Map<SyncCategory, CategoryData>
90+
): CategoryData {
91+
if (isBlobCategoryData(pulled)) return pulled;
92+
if (!isItemCategoryData(pulled)) return pulled;
93+
94+
const local = localByCategory.get(pulled.category);
95+
if (local && isItemCategoryData(local)) {
96+
return mergeItemData(local, pulled);
97+
}
98+
return pulled;
99+
}
100+
101+
/**
102+
* Merge local and pulled data for complete state tracking.
103+
* After a pull, we need to track ALL items (local + pulled) for proper deletion detection.
104+
*/
105+
export function mergeDataForState(
106+
localData: CategoryData[] | undefined,
107+
pulledData: CategoryData[]
108+
): CategoryData[] {
109+
if (!localData) return pulledData;
110+
111+
const localByCategory = buildCategoryMap(localData);
112+
const processedCategories = new Set<SyncCategory>();
113+
const result: CategoryData[] = [];
114+
115+
// Process pulled data first (it takes precedence for blobs)
116+
for (const pulled of pulledData) {
117+
processedCategories.add(pulled.category);
118+
result.push(processPulledCategory(pulled, localByCategory));
119+
}
120+
121+
// Add local-only categories (not in pulled)
122+
for (const local of localData) {
123+
if (!processedCategories.has(local.category)) result.push(local);
124+
}
125+
126+
return result;
127+
}
128+
63129
/**
64130
* Build storage files map from backend listing.
65131
*/

src/sync/engine/sync-engine.ts

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,12 @@ import type { CategoryData } from '../operations/types.js';
1414
import type { SyncEngineOptions } from './types.js';
1515
import { MANIFEST_FILENAME } from './types.js';
1616
import { fetchManifest } from './manifest.js';
17-
import { buildLocalState, isLockedByOther, getStorageFilesMap } from './state.js';
17+
import {
18+
buildLocalState,
19+
isLockedByOther,
20+
getStorageFilesMap,
21+
mergeDataForState,
22+
} from './state.js';
1823
import {
1924
buildPushResult,
2025
buildPullResult,
@@ -180,7 +185,10 @@ export class SyncEngine {
180185
data
181186
);
182187
const { pulledData, changedCategories, tombstonedItems } = await pullCategories(opts);
183-
this.localState = buildLocalState(m, pulledData, this.getStorageId(), this.config.machineId);
188+
// Merge local and pulled data for complete state tracking
189+
// This ensures local-only items are tracked for subsequent push detection
190+
const mergedData = mergeDataForState(data, pulledData);
191+
this.localState = buildLocalState(m, mergedData, this.getStorageId(), this.config.machineId);
184192
// Convert tombstonedItems to item ID arrays for the result
185193
const tombstoneIds = extractTombstoneIds(tombstonedItems);
186194
return buildPullResult({ changedCategories, pulledData, tombstonedItems: tombstoneIds });

0 commit comments

Comments
 (0)