Skip to content

Commit 2b16414

Browse files
committed
v0.1.15: Fetch and write remote items during push
When pushing local changes, now also: 1. Fetches remote items from shards that don't exist locally 2. Returns them as pulledData in the push result 3. Sync handler writes them to disk after successful push This fixes the issue where push would merge remote data into the manifest but never download it to the local filesystem, causing sessions/messages from other machines to not appear locally.
1 parent 38a3fc9 commit 2b16414

6 files changed

Lines changed: 156 additions & 8 deletions

File tree

package-lock.json

Lines changed: 2 additions & 2 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "oc-sync",
3-
"version": "0.1.14",
3+
"version": "0.1.15",
44
"description": "Sync OpenCode data across machines using a private GitHub repository with vector clock conflict resolution",
55
"main": "dist/index.js",
66
"types": "dist/index.d.ts",

src/plugin/sync-handler.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -62,7 +62,8 @@ async function handleSyncSuccess(
6262
const newState = state.engine?.getLocalState();
6363

6464
// Write pulled data to local filesystem
65-
if (result.action === 'pulled' || result.action === 'merged') {
65+
// This includes data from pull, merge, AND remote items fetched during push
66+
if (result.action === 'pulled' || result.action === 'merged' || result.action === 'pushed') {
6667
syncLog(
6768
`[WRITE] Action: ${result.action}, pulledData: ${result.pulledData ? 'present' : 'missing'}`
6869
);

src/sync/engine/operations.ts

Lines changed: 18 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@ import {
2626
extractTombstoneIds,
2727
fetchResolvedShards,
2828
} from './helpers.js';
29+
import { fetchRemoteItemsNotLocal } from './remote-fetch.js';
2930

3031
export interface OperationContext {
3132
backend: StorageBackend;
@@ -44,7 +45,7 @@ export async function executePushOperation(
4445
ctx: PushContext,
4546
data: CategoryData[],
4647
remote?: Manifest
47-
): Promise<{ result: SyncResult; newState: LocalSyncState }> {
48+
): Promise<{ result: SyncResult; newState: LocalSyncState; remoteItems?: CategoryData[] }> {
4849
const existing = (await ctx.backend.listFiles()).map((f) => f.filename);
4950

5051
// Pre-fetch shards for sharded categories to enable proper merging
@@ -64,7 +65,22 @@ export async function executePushOperation(
6465
toStorageFiles(files, MANIFEST_FILENAME, JSON.stringify(manifest, null, 2))
6566
);
6667
const newState = buildLocalState(manifest, data, ctx.getStorageId(), ctx.config.machineId);
67-
return { result: buildPushResult(changedCategories), newState };
68+
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+
}
83+
return { result, newState };
6884
}
6985

7086
/** Execute a pull operation and return updated local state */

src/sync/engine/remote-fetch.ts

Lines changed: 113 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,113 @@
1+
/**
2+
* Remote Fetch Helpers
3+
*
4+
* Functions to fetch remote items during push operations.
5+
*/
6+
7+
import type { StorageBackend } from '../../storage/index.js';
8+
import type { SyncCategory } from '../../types/index.js';
9+
import type { CategoryData, ItemCategoryData, ResolvedShard } from '../operations/types.js';
10+
import { unpackItem } from '../item-packer.js';
11+
import { syncLog } from './logger.js';
12+
13+
/** Info about a remote file to fetch */
14+
interface RemoteFetchInfo {
15+
category: SyncCategory;
16+
itemId: string;
17+
filename: string;
18+
checksum: string;
19+
}
20+
21+
/** Build a map of local item IDs per category */
22+
function buildLocalItemIdMap(localData: CategoryData[]): Map<SyncCategory, Set<string>> {
23+
const map = new Map<SyncCategory, Set<string>>();
24+
for (const cat of localData) {
25+
if (cat.type === 'items') {
26+
const set = map.get(cat.category) ?? new Set<string>();
27+
for (const id of Object.keys(cat.items)) {
28+
set.add(id);
29+
}
30+
map.set(cat.category, set);
31+
}
32+
}
33+
return map;
34+
}
35+
36+
/** Collect files to fetch from shards (excluding local and tombstoned items) */
37+
function collectFilesToFetch(
38+
shards: Record<SyncCategory, ResolvedShard>,
39+
localItemIds: Map<SyncCategory, Set<string>>
40+
): RemoteFetchInfo[] {
41+
const filesToFetch: RemoteFetchInfo[] = [];
42+
43+
for (const [category, shard] of Object.entries(shards) as [SyncCategory, ResolvedShard][]) {
44+
const localIds = localItemIds.get(category) ?? new Set<string>();
45+
const tombstoneIds = new Set(Object.keys(shard.tombstones));
46+
47+
for (const [itemId, info] of Object.entries(shard.items)) {
48+
if (!localIds.has(itemId) && !tombstoneIds.has(itemId)) {
49+
filesToFetch.push({ category, itemId, filename: info.filename, checksum: info.checksum });
50+
}
51+
}
52+
}
53+
54+
return filesToFetch;
55+
}
56+
57+
/** Process downloaded content and group by category */
58+
function processDownloadedContent(
59+
filesToFetch: RemoteFetchInfo[],
60+
contents: Record<string, string | null>
61+
): Map<SyncCategory, { items: Record<string, string>; checksums: Record<string, string> }> {
62+
const categoryItems = new Map<
63+
SyncCategory,
64+
{ items: Record<string, string>; checksums: Record<string, string> }
65+
>();
66+
67+
for (const { category, itemId, filename, checksum } of filesToFetch) {
68+
const content = contents[filename];
69+
if (!content) continue;
70+
71+
try {
72+
const unpacked = unpackItem(filename, content, checksum);
73+
let catData = categoryItems.get(category);
74+
if (!catData) {
75+
catData = { items: {}, checksums: {} };
76+
categoryItems.set(category, catData);
77+
}
78+
catData.items[itemId] = unpacked.content;
79+
catData.checksums[itemId] = checksum;
80+
} catch (error) {
81+
console.error(`Failed to unpack remote item ${itemId}:`, error);
82+
}
83+
}
84+
85+
return categoryItems;
86+
}
87+
88+
/** Fetch remote items from shards that don't exist in local data */
89+
export async function fetchRemoteItemsNotLocal(
90+
backend: StorageBackend,
91+
shards: Record<SyncCategory, ResolvedShard>,
92+
localData: CategoryData[]
93+
): Promise<CategoryData[]> {
94+
const localItemIds = buildLocalItemIdMap(localData);
95+
const filesToFetch = collectFilesToFetch(shards, localItemIds);
96+
97+
if (filesToFetch.length === 0) return [];
98+
99+
syncLog(`[PUSH] Fetching ${String(filesToFetch.length)} remote items to write locally`);
100+
const contents = await backend.getFiles(filesToFetch.map((f) => f.filename));
101+
const categoryItems = processDownloadedContent(filesToFetch, contents);
102+
103+
// Convert to CategoryData[]
104+
const result: CategoryData[] = [];
105+
for (const [category, { items, checksums }] of categoryItems) {
106+
if (Object.keys(items).length > 0) {
107+
syncLog(`[PUSH] Downloaded ${String(Object.keys(items).length)} remote ${category} items`);
108+
result.push({ category, type: 'items', items, checksums } as ItemCategoryData);
109+
}
110+
}
111+
112+
return result;
113+
}

src/sync/engine/result.ts

Lines changed: 20 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,16 +6,34 @@
66

77
import type { SyncResult, ConflictInfo, SyncCategory } from '../../types/index.js';
88

9+
/** Options for building push result */
10+
export interface BuildPushResultOptions {
11+
changedCategories: SyncCategory[];
12+
pulledData?: unknown;
13+
}
14+
915
/**
1016
* Build a success result for push operations.
1117
*/
12-
export function buildPushResult(changedCategories: SyncCategory[]): SyncResult {
13-
return {
18+
export function buildPushResult(
19+
changedCategoriesOrOpts: SyncCategory[] | BuildPushResultOptions
20+
): SyncResult {
21+
// Support both old signature (array) and new signature (options object)
22+
const opts = Array.isArray(changedCategoriesOrOpts)
23+
? { changedCategories: changedCategoriesOrOpts }
24+
: changedCategoriesOrOpts;
25+
26+
const { changedCategories, pulledData } = opts;
27+
const result: SyncResult = {
1428
success: true,
1529
action: 'pushed',
1630
message: `Pushed ${String(changedCategories.length)} categories`,
1731
changedCategories,
1832
};
33+
if (pulledData) {
34+
result.pulledData = pulledData;
35+
}
36+
return result;
1937
}
2038

2139
/** Options for building pull result */

0 commit comments

Comments
 (0)