Skip to content

Commit 0027af3

Browse files
committed
refactor: migrate to tree-indexed sync for all categories
- Removed support for blob-based categories, transitioning to a unified tree-indexed sync approach. - Updated category data structures and types to reflect the new sync model. - Simplified merge and pull operations to handle only per-item categories. - Enhanced tombstone management by separating tombstones into a dedicated file. - Updated schema version to 5.0 to indicate the new sync structure. - Refactored related functions and types across the codebase to align with the new architecture.
1 parent e4e7d07 commit 0027af3

File tree

18 files changed

+148
-754
lines changed

18 files changed

+148
-754
lines changed

src/data/category-loader.ts

Lines changed: 4 additions & 45 deletions
Original file line numberDiff line numberDiff line change
@@ -2,18 +2,16 @@
22
* Category Loader
33
*
44
* Load data for sync categories.
5-
* Supports both blob-based (config, state, etc.) and per-item (sessions, messages) loading.
5+
* All categories use per-item (tree-indexed) loading - each file synced individually.
66
*/
77

88
import { readFile, readdir, stat } from 'node:fs/promises';
99
import { join, basename } from 'node:path';
1010
import type { SyncCategory, PathConfig, SyncConfig } from '../types/index.js';
1111
import type { LocalSyncState } from '../types/sync.js';
1212
import type { Tombstone } from '../types/manifest.js';
13-
import { shouldUseItemSync } from '../types/manifest.js';
1413
import { getCategoryPaths } from '../types/paths.js';
15-
import type { CategoryData, BlobCategoryData, ItemCategoryData } from '../sync/operations/types.js';
16-
import { loadSinglePath, getPathKey } from './directory-loader.js';
14+
import type { CategoryData, ItemCategoryData } from '../sync/operations/types.js';
1715
import { calculateChecksum } from '../sync/item-packer.js';
1816
import { createTombstone, detectLocalDeletions } from '../sync/tombstone.js';
1917

@@ -53,7 +51,7 @@ export async function loadLocalData(
5351
if (!enabledCategories[category as SyncCategory]) continue;
5452

5553
try {
56-
const data = await loadCategoryData(category as SyncCategory, paths, options);
54+
const data = await loadItemCategoryData(category as SyncCategory, paths, options);
5755
if (data) categories.push(data);
5856
} catch (error) {
5957
const firstPath = paths[0];
@@ -71,46 +69,7 @@ export async function loadLocalData(
7169
}
7270

7371
/**
74-
* Load data for a single category.
75-
* Routes to blob or per-item loading based on category.
76-
*/
77-
async function loadCategoryData(
78-
category: SyncCategory,
79-
paths: string[],
80-
options: LoadOptions
81-
): Promise<CategoryData | null> {
82-
if (shouldUseItemSync(category)) {
83-
return loadItemCategoryData(category, paths, options);
84-
}
85-
return loadBlobCategoryData(category, paths);
86-
}
87-
88-
/**
89-
* Load blob-based category data (legacy approach).
90-
*/
91-
async function loadBlobCategoryData(
92-
category: SyncCategory,
93-
paths: string[]
94-
): Promise<BlobCategoryData | null> {
95-
const categoryData: Record<string, unknown> = {};
96-
let hasData = false;
97-
98-
for (const basePath of paths) {
99-
const result = await loadSinglePath(basePath);
100-
if (result !== null) {
101-
categoryData[getPathKey(basePath)] = result;
102-
hasData = true;
103-
}
104-
}
105-
106-
if (!hasData) return null;
107-
108-
const isJsonl = category === 'state';
109-
return { category, type: 'blob', data: JSON.stringify(categoryData), isJsonl };
110-
}
111-
112-
/**
113-
* Load per-item category data (sessions, messages).
72+
* Load per-item category data.
11473
* Each file is loaded as a separate item with its own checksum.
11574
* Also detects locally deleted items and creates tombstones for them.
11675
*/

src/data/writer.ts

Lines changed: 4 additions & 87 deletions
Original file line numberDiff line numberDiff line change
@@ -2,20 +2,18 @@
22
* Data Writer
33
*
44
* Writes synced data back to local filesystem.
5-
* Supports both blob-based overwrite and per-item merge writes.
5+
* All categories use per-item writes.
66
*/
77

88
import { mkdir, writeFile, unlink } from 'node:fs/promises';
99
import { join, dirname } from 'node:path';
1010
import type { PathConfig, SyncCategory } from '../types/index.js';
1111
import { getCategoryPaths } from '../types/paths.js';
1212
import type { CategoryData, ItemCategoryData } from '../sync/operations/types.js';
13-
import { isBlobCategoryData, isItemCategoryData } from '../sync/operations/types.js';
1413

1514
/**
1615
* Write synced data back to local filesystem.
17-
* - Blob categories: overwrites local data
18-
* - Item categories: merges new items (does NOT overwrite existing)
16+
* All categories use per-item writes.
1917
*/
2018
export async function writeLocalData(
2119
pathConfig: PathConfig,
@@ -27,28 +25,8 @@ export async function writeLocalData(
2725
const paths = categoryPaths[catData.category];
2826
if (paths.length === 0) continue;
2927

30-
if (isItemCategoryData(catData)) {
31-
// Per-item merge write
32-
await writeItemCategoryData(catData.category, paths, catData);
33-
} else if (isBlobCategoryData(catData)) {
34-
// Blob overwrite
35-
const parsed = JSON.parse(catData.data) as Record<string, unknown>;
36-
await writeBlobCategoryData(paths, parsed);
37-
}
38-
}
39-
}
40-
41-
/**
42-
* Write blob-based category data to filesystem (overwrites).
43-
*/
44-
async function writeBlobCategoryData(
45-
paths: string[],
46-
data: Record<string, unknown>
47-
): Promise<void> {
48-
for (const [key, value] of Object.entries(data)) {
49-
const targetPath = findTargetPath(paths, key);
50-
if (targetPath === undefined) continue;
51-
await writeEntry(targetPath, value);
28+
// Per-item write
29+
await writeItemCategoryData(catData.category, paths, catData);
5230
}
5331
}
5432

@@ -99,67 +77,6 @@ async function writeItemFile(filePath: string, content: string): Promise<void> {
9977
}
10078
}
10179

102-
/**
103-
* Find target path for a key.
104-
*/
105-
function findTargetPath(paths: string[], key: string): string | undefined {
106-
return paths.find((p) => p.endsWith(key)) ?? paths[0];
107-
}
108-
109-
/**
110-
* Write a single entry (file or directory).
111-
*/
112-
async function writeEntry(targetPath: string, value: unknown): Promise<void> {
113-
try {
114-
if (isDirectoryValue(value)) {
115-
await mkdir(targetPath, { recursive: true });
116-
await writeDirectoryData(targetPath, value as Record<string, unknown>);
117-
} else {
118-
await ensureParentDir(targetPath);
119-
const content = serializeContent(targetPath, value);
120-
await writeFile(targetPath, content, 'utf-8');
121-
}
122-
} catch (error) {
123-
console.error(`Failed to write ${targetPath}:`, error);
124-
}
125-
}
126-
127-
/**
128-
* Check if value represents a directory.
129-
*/
130-
function isDirectoryValue(value: unknown): boolean {
131-
return typeof value === 'object' && value !== null && !Array.isArray(value);
132-
}
133-
134-
/**
135-
* Write directory contents recursively.
136-
*/
137-
async function writeDirectoryData(dirPath: string, data: Record<string, unknown>): Promise<void> {
138-
for (const [name, value] of Object.entries(data)) {
139-
const fullPath = join(dirPath, name);
140-
await writeEntry(fullPath, value);
141-
}
142-
}
143-
144-
/**
145-
* Serialize content based on file type.
146-
*/
147-
function serializeContent(filePath: string, value: unknown): string {
148-
if (filePath.endsWith('.json')) {
149-
return JSON.stringify(value, null, 2);
150-
}
151-
152-
if (filePath.endsWith('.jsonl') && Array.isArray(value)) {
153-
return value.map((item) => JSON.stringify(item)).join('\n');
154-
}
155-
156-
if (typeof value === 'string') {
157-
return value;
158-
}
159-
160-
return JSON.stringify(value, null, 2);
161-
}
162-
16380
/**
16481
* Ensure parent directory exists.
16582
*/

src/sync/engine/helpers.ts

Lines changed: 3 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -12,13 +12,13 @@ import type {
1212
Tombstone,
1313
} from '../../types/index.js';
1414
import type { CategoryData, PassphraseOption } from '../operations/types.js';
15-
import { isItemCategoryData } from '../operations/types.js';
1615
import type { PullOptions } from '../operations/pull.js';
1716
import type { PreparePushResult } from '../operations/push.js';
1817
import { preparePushData } from '../operations/push.js';
1918

2019
/**
21-
* Build checksums map from local item category data for merge-based pull.
20+
* Build checksums map from local category data for merge-based pull.
21+
* All categories use per-item sync.
2222
*/
2323
export function buildLocalChecksums(
2424
localData?: CategoryData[]
@@ -31,9 +31,7 @@ export function buildLocalChecksums(
3131
>;
3232

3333
for (const catData of localData) {
34-
if (isItemCategoryData(catData)) {
35-
checksums[catData.category] = catData.checksums;
36-
}
34+
checksums[catData.category] = catData.checksums;
3735
}
3836

3937
return Object.keys(checksums).length > 0 ? checksums : undefined;

src/sync/engine/index.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@ export { SyncError } from './errors.js';
99
export type { SyncEngineOptions } from './types.js';
1010
export { MANIFEST_FILENAME } from './types.js';
1111
export { fetchManifest } from './manifest.js';
12-
export { buildLocalState, isLockedByOther, getStorageFilesMap } from './state.js';
12+
export { buildLocalState, isLockedByOther } from './state.js';
1313
export {
1414
buildPushResult,
1515
buildPullResult,

src/sync/engine/operations.ts

Lines changed: 2 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@ import { pullCategories } from '../operations/pull.js';
1111
import { mergeAllCategories } from '../operations/merge-operation.js';
1212
import { syncLog } from './logger.js';
1313
import { MANIFEST_FILENAME } from './types.js';
14-
import { buildLocalState, getStorageFilesMap, mergeDataForState } from './state.js';
14+
import { buildLocalState, mergeDataForState } from './state.js';
1515
import {
1616
buildPushResult,
1717
buildPullResult,
@@ -72,11 +72,9 @@ export async function executePullOperation(
7272
data?: CategoryData[]
7373
): Promise<{ result: SyncResult; newState: LocalSyncState | null }> {
7474
if (!remote) return { result: buildErrorResult('No remote data found'), newState: null };
75-
const sf = await getStorageFilesMap(ctx.backend);
7675
const opts = buildPullOptions(
7776
{
7877
manifest: remote,
79-
storageFiles: sf,
8078
enabledCategories: ctx.config.sync,
8179
passphrase: buildCryptoOptions(ctx.passphrase, ctx.oldPassphrase),
8280
backend: ctx.backend,
@@ -105,15 +103,13 @@ export async function executeConflictOperation(
105103
manifest: Manifest,
106104
retry: number
107105
): Promise<SyncResult> {
108-
const sf = await getStorageFilesMap(ctx.backend);
109106
const mergeCtx = {
110107
remoteManifest: manifest,
111-
storageFiles: sf,
112108
localState: ctx.localState,
113109
passphrase: buildCryptoOptions(ctx.passphrase, ctx.oldPassphrase),
114110
machineId: ctx.config.machineId,
115111
backend: ctx.backend,
116112
};
117-
const { mergedData, conflicts } = await mergeAllCategories(data, mergeCtx);
113+
const { mergedData, conflicts } = mergeAllCategories(data, mergeCtx);
118114
return buildConflictResult(await ctx.pushFn(mergedData, retry, manifest), conflicts);
119115
}

src/sync/engine/routing.ts

Lines changed: 4 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -31,16 +31,15 @@ export function determineAction(
3131
const remoteTimestamp = remoteManifest.updatedAt;
3232

3333
const cmp = compareTimestamps(localTimestamp, remoteTimestamp);
34-
const pushNeeded = cmp === 'equal' ? needsPush(localData, remoteManifest) : false;
34+
// Check needsPush for both 'equal' and 'local-newer' cases
35+
const pushNeeded = cmp !== 'remote-newer' ? needsPush(localData, remoteManifest) : false;
3536
syncLog(`[SYNC] Timestamp: ${cmp}, needsPush=${String(pushNeeded)}`);
3637

3738
switch (cmp) {
3839
case 'equal':
39-
// Same timestamp - use checksum to decide
40-
return pushNeeded ? { action: 'push' } : { action: 'no-change' };
4140
case 'local-newer':
42-
// Local has newer changes - push them
43-
return { action: 'push' };
41+
// Same or local newer - only push if there are actual changes
42+
return pushNeeded ? { action: 'push' } : { action: 'no-change' };
4443
case 'remote-newer':
4544
// Remote has newer changes - pull first, then push if local has changes
4645
// This implements last-write-wins: remote wins, we'll push our changes after

src/sync/engine/state.ts

Lines changed: 6 additions & 35 deletions
Original file line numberDiff line numberDiff line change
@@ -4,15 +4,12 @@
44
* Handles local state updates after sync operations.
55
*/
66

7-
import { calculateChecksum } from '../packer.js';
8-
import type { StorageBackend } from '../../storage/index.js';
97
import type { Manifest, LocalSyncState, SyncCategory } from '../../types/index.js';
10-
import type { CategoryData, StorageFiles } from '../operations/types.js';
11-
import { isBlobCategoryData, isItemCategoryData } from '../operations/types.js';
8+
import type { CategoryData } from '../operations/types.js';
129

1310
/**
1411
* Build updated local state after a sync operation.
15-
* Only tracks base versions for blob categories (used for three-way merge).
12+
* All categories use per-item tracking.
1613
*/
1714
export function buildLocalState(
1815
manifest: Manifest,
@@ -26,14 +23,8 @@ export function buildLocalState(
2623
const itemChecksums: Partial<Record<SyncCategory, Record<string, string>>> = {};
2724

2825
for (const item of data) {
29-
if (isBlobCategoryData(item)) {
30-
// Blob categories: track single checksum and base version for three-way merge
31-
checksums[item.category] = calculateChecksum(item.data);
32-
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;
36-
}
26+
// All categories: track individual item checksums for deletion detection
27+
itemChecksums[item.category] = item.checksums;
3728
}
3829

3930
return {
@@ -71,9 +62,7 @@ function buildCategoryMap(data: CategoryData[]): Map<SyncCategory, CategoryData>
7162
}
7263

7364
/** Merge item category data (local + pulled) */
74-
import type { ItemCategoryData } from '../operations/types.js';
75-
76-
function mergeItemData(local: ItemCategoryData, pulled: ItemCategoryData): CategoryData {
65+
function mergeItemData(local: CategoryData, pulled: CategoryData): CategoryData {
7766
return {
7867
category: pulled.category,
7968
type: 'items',
@@ -87,11 +76,8 @@ function processPulledCategory(
8776
pulled: CategoryData,
8877
localByCategory: Map<SyncCategory, CategoryData>
8978
): CategoryData {
90-
if (isBlobCategoryData(pulled)) return pulled;
91-
if (!isItemCategoryData(pulled)) return pulled;
92-
9379
const local = localByCategory.get(pulled.category);
94-
if (local && isItemCategoryData(local)) {
80+
if (local) {
9581
return mergeItemData(local, pulled);
9682
}
9783
return pulled;
@@ -124,18 +110,3 @@ export function mergeDataForState(
124110

125111
return result;
126112
}
127-
128-
/**
129-
* Build storage files map from backend listing.
130-
*/
131-
export async function getStorageFilesMap(backend: StorageBackend): Promise<StorageFiles> {
132-
const files = await backend.listFiles();
133-
const map: StorageFiles = {};
134-
for (const file of files) {
135-
const entry: { content?: string; sha?: string } = {};
136-
if (file.content !== undefined) entry.content = file.content;
137-
if (file.sha !== undefined) entry.sha = file.sha;
138-
map[file.filename] = entry;
139-
}
140-
return map;
141-
}

src/sync/index.ts

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@ export type { SyncEngineOptions } from './engine/index.js';
1111
// Operations
1212
export type { CategoryData } from './operations/types.js';
1313
export { preparePushData, needsPush } from './operations/push.js';
14-
export { pullCategories, downloadChunks } from './operations/pull.js';
14+
export { pullCategories } from './operations/pull.js';
1515
export { mergeAllCategories } from './operations/merge-operation.js';
1616

1717
// Timestamp-based sync (replaces vector clocks)
@@ -40,12 +40,11 @@ export {
4040
createTombstone,
4141
isTombstoneExpired,
4242
filterExpiredTombstones,
43-
cleanupExpiredTombstones,
4443
mergeTombstones,
4544
isItemTombstoned,
4645
getItemsToDelete,
4746
detectLocalDeletions,
48-
// TombstonesFile handling (schema 4.0)
47+
// TombstonesFile handling (schema 5.0)
4948
parseTombstonesFile,
5049
serializeTombstonesFile,
5150
getCategoryTombstones,

0 commit comments

Comments
 (0)