Skip to content

Commit e4e7d07

Browse files
committed
feat: transition to tree-indexed categories (schema 4.0)
- Removed sharding support and replaced it with tree-indexed category handling. - Updated manifest and operations to accommodate new category structure. - Introduced tombstones management for tracking deleted items separately. - Enhanced push and pull operations to work with tree-indexed categories. - Updated types and interfaces to reflect changes in category management. - Refactored related helper functions for better clarity and performance.
1 parent 6e17c53 commit e4e7d07

13 files changed

Lines changed: 367 additions & 439 deletions

File tree

src/sync/engine/helpers.ts

Lines changed: 2 additions & 48 deletions
Original file line numberDiff line numberDiff line change
@@ -11,15 +11,11 @@ import type {
1111
SyncConfig,
1212
Tombstone,
1313
} from '../../types/index.js';
14-
import { isShardedRef } from '../../types/index.js';
15-
import type { CategoryData, PassphraseOption, ResolvedShard } from '../operations/types.js';
14+
import type { CategoryData, PassphraseOption } from '../operations/types.js';
1615
import { isItemCategoryData } from '../operations/types.js';
1716
import type { PullOptions } from '../operations/pull.js';
1817
import type { PreparePushResult } from '../operations/push.js';
1918
import { preparePushData } from '../operations/push.js';
20-
import type { StorageBackend } from '../../storage/index.js';
21-
import { fetchCategoryShard } from './manifest.js';
22-
import { syncLog } from './logger.js';
2319

2420
/**
2521
* Build checksums map from local item category data for merge-based pull.
@@ -67,18 +63,11 @@ export interface PushOptsBase {
6763
}
6864

6965
/** Execute push and return files for storage */
70-
export function executePush(
71-
opts: PushOptsBase,
72-
remoteManifest?: Manifest,
73-
resolvedShards?: Record<SyncCategory, ResolvedShard>
74-
): PreparePushResult {
66+
export function executePush(opts: PushOptsBase, remoteManifest?: Manifest): PreparePushResult {
7567
const pushOpts = { ...opts } as Parameters<typeof preparePushData>[0];
7668
if (remoteManifest) {
7769
pushOpts.remoteManifest = remoteManifest;
7870
}
79-
if (resolvedShards) {
80-
pushOpts.resolvedShards = resolvedShards;
81-
}
8271
return preparePushData(pushOpts);
8372
}
8473

@@ -120,38 +109,3 @@ export function extractTombstoneIds(
120109
}
121110
return result;
122111
}
123-
124-
/**
125-
* Fetch resolved shard data for sharded categories.
126-
* This ensures we merge with existing remote data instead of overwriting.
127-
*/
128-
export async function fetchResolvedShards(
129-
backend: StorageBackend,
130-
remote?: Manifest
131-
): Promise<Record<SyncCategory, ResolvedShard> | undefined> {
132-
if (!remote) return undefined;
133-
134-
const shardedCategories: SyncCategory[] = ['sessions', 'messages'];
135-
const resolved: Record<SyncCategory, ResolvedShard> = {} as Record<SyncCategory, ResolvedShard>;
136-
let hasAny = false;
137-
138-
for (const category of shardedCategories) {
139-
const info = remote.categories[category];
140-
if (info && isShardedRef(info)) {
141-
syncLog(`[PUSH] Fetching shard for ${category}: ${info.shardFile}`);
142-
const shard = await fetchCategoryShard(backend, info.shardFile);
143-
if (shard) {
144-
resolved[category] = {
145-
items: shard.items,
146-
tombstones: shard.tombstones,
147-
};
148-
hasAny = true;
149-
syncLog(
150-
`[PUSH] Loaded ${String(Object.keys(shard.items).length)} remote ${category} for merge`
151-
);
152-
}
153-
}
154-
}
155-
156-
return hasAny ? resolved : undefined;
157-
}

src/sync/engine/manifest.ts

Lines changed: 2 additions & 66 deletions
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,13 @@
11
/**
22
* Manifest Operations
33
*
4-
* Handles fetching and parsing the remote manifest, including sharded manifests.
4+
* Handles fetching and parsing the remote manifest.
55
*/
66

77
import type { StorageBackend } from '../../storage/index.js';
88
import { RepoApiError } from '../../storage/index.js';
9-
import type { Manifest, CategoryShard, ItemCategoryInfo } from '../../types/index.js';
10-
import { isShardedRef, type SyncCategory } from '../../types/index.js';
9+
import type { Manifest } from '../../types/index.js';
1110
import { MANIFEST_FILENAME } from './types.js';
12-
import { syncLog } from './logger.js';
1311

1412
/**
1513
* Fetch and parse the manifest from storage.
@@ -27,65 +25,3 @@ export async function fetchManifest(backend: StorageBackend): Promise<Manifest |
2725
throw error;
2826
}
2927
}
30-
31-
/**
32-
* Fetch a category shard from storage.
33-
*/
34-
export async function fetchCategoryShard(
35-
backend: StorageBackend,
36-
shardFile: string
37-
): Promise<CategoryShard | null> {
38-
let content: string | null = null;
39-
try {
40-
content = await backend.getFile(shardFile);
41-
if (!content) return null;
42-
return JSON.parse(content) as CategoryShard;
43-
} catch (error) {
44-
if (error instanceof RepoApiError && error.status === 404) {
45-
return null;
46-
}
47-
// Log JSON parse errors for debugging with content preview
48-
if (error instanceof SyntaxError) {
49-
const preview = content ? content.slice(0, 200) : 'null';
50-
syncLog(`[MANIFEST] JSON parse error in ${shardFile}: ${error.message}`);
51-
syncLog(`[MANIFEST] Content preview: ${preview}`);
52-
}
53-
throw error;
54-
}
55-
}
56-
57-
/**
58-
* Resolve a category's full info, loading shard if needed.
59-
* Returns ItemCategoryInfo for sharded categories.
60-
*/
61-
export async function resolveCategoryInfo(
62-
manifest: Manifest,
63-
category: SyncCategory,
64-
backend: StorageBackend
65-
): Promise<ItemCategoryInfo | null> {
66-
const info = manifest.categories[category];
67-
if (!info) return null;
68-
69-
// If it's already an item category, return as-is
70-
if (info.type === 'items') {
71-
return info;
72-
}
73-
74-
// If it's a sharded reference, load the shard
75-
if (isShardedRef(info)) {
76-
const shard = await fetchCategoryShard(backend, info.shardFile);
77-
if (!shard) return null;
78-
79-
// Convert shard to ItemCategoryInfo
80-
return {
81-
type: 'items',
82-
items: shard.items,
83-
tombstones: shard.tombstones,
84-
itemCount: Object.keys(shard.items).length,
85-
lastModified: info.lastModified,
86-
lastModifiedBy: info.lastModifiedBy,
87-
};
88-
}
89-
90-
return null;
91-
}

src/sync/engine/operations.ts

Lines changed: 3 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -24,9 +24,7 @@ import {
2424
toStorageFiles,
2525
buildCryptoOptions,
2626
extractTombstoneIds,
27-
fetchResolvedShards,
2827
} from './helpers.js';
29-
import { fetchRemoteItemsNotLocal } from './remote-fetch.js';
3028

3129
export interface OperationContext {
3230
backend: StorageBackend;
@@ -45,41 +43,25 @@ export async function executePushOperation(
4543
ctx: PushContext,
4644
data: CategoryData[],
4745
remote?: Manifest
48-
): Promise<{ result: SyncResult; newState: LocalSyncState; remoteItems?: CategoryData[] }> {
46+
): Promise<{ result: SyncResult; newState: LocalSyncState }> {
4947
const existing = (await ctx.backend.listFiles()).map((f) => f.filename);
5048

51-
// Pre-fetch shards for sharded categories to enable proper merging
52-
const resolvedShards = await fetchResolvedShards(ctx.backend, remote);
53-
5449
const opts = {
5550
localData: data,
5651
config: ctx.config,
5752
localState: ctx.localState,
5853
passphrase: buildCryptoOptions(ctx.passphrase, ctx.oldPassphrase),
5954
existingFiles: existing,
6055
};
61-
const { files, manifest, changedCategories } = executePush(opts, remote, resolvedShards);
56+
const { files, manifest, changedCategories } = executePush(opts, remote);
6257
const fileCount = Object.keys(files).length;
6358
syncLog(`[SYNC] Push: ${String(fileCount)} files, ${String(existing.length)} existing`);
6459
await ctx.backend.updateFiles(
6560
toStorageFiles(files, MANIFEST_FILENAME, JSON.stringify(manifest, null, 2))
6661
);
6762
const newState = buildLocalState(manifest, data, ctx.getStorageId(), ctx.config.machineId);
6863

69-
// Fetch remote items we don't have locally (for writing to disk)
70-
const remoteItems = resolvedShards
71-
? await fetchRemoteItemsNotLocal(ctx.backend, resolvedShards, data)
72-
: undefined;
73-
74-
const result = buildPushResult({
75-
changedCategories,
76-
pulledData: remoteItems && remoteItems.length > 0 ? remoteItems : undefined,
77-
});
78-
79-
// Only include remoteItems in return if there are any
80-
if (remoteItems && remoteItems.length > 0) {
81-
return { result, newState, remoteItems };
82-
}
64+
const result = buildPushResult({ changedCategories });
8365
return { result, newState };
8466
}
8567

src/sync/engine/remote-fetch.ts

Lines changed: 0 additions & 122 deletions
This file was deleted.

src/sync/index.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -45,5 +45,13 @@ export {
4545
isItemTombstoned,
4646
getItemsToDelete,
4747
detectLocalDeletions,
48+
// TombstonesFile handling (schema 4.0)
49+
parseTombstonesFile,
50+
serializeTombstonesFile,
51+
getCategoryTombstones,
52+
setCategoryTombstones,
53+
addTombstone,
54+
mergeTombstonesFiles,
55+
filterExpiredTombstonesFile,
4856
} from './tombstone.js';
4957
export type { FilterTombstonesResult } from './tombstone.js';

src/sync/operations/helpers.ts

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -83,7 +83,7 @@ export function createManifest(
8383
): Manifest {
8484
return {
8585
version: (localState?.lastSyncedVersion ?? 0) + 1,
86-
schemaVersion: '3.0',
86+
schemaVersion: '4.0',
8787
createdAt: localState?.lastSyncedAt ?? now,
8888
updatedAt: now,
8989
lastUpdatedBy: machineId,
@@ -103,6 +103,9 @@ export function addSyncHistory(ctx: PushContext, categories: SyncCategory[]): vo
103103
ctx.manifest.recentSyncs = [entry].slice(0, MAX_SYNC_HISTORY);
104104
}
105105

106+
/** Files that should never be marked as orphaned */
107+
const PROTECTED_FILES = new Set(['manifest.json', 'tombstones.json']);
108+
106109
/** Mark orphaned files for deletion. */
107110
export function markOrphanedFiles(
108111
files: Record<string, { content: string | null }>,
@@ -111,7 +114,7 @@ export function markOrphanedFiles(
111114
): void {
112115
if (!existingFiles) return;
113116
for (const filename of existingFiles) {
114-
if (filename !== 'manifest.json' && !newFiles.has(filename)) {
117+
if (!PROTECTED_FILES.has(filename) && !newFiles.has(filename)) {
115118
files[filename] = { content: null };
116119
}
117120
}

0 commit comments

Comments
 (0)