gsd-agent 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +221 -0
- package/bin/cli.js +313 -0
- package/dist/auth-flow.d.ts +50 -0
- package/dist/auth-flow.d.ts.map +1 -0
- package/dist/auth-flow.js +233 -0
- package/dist/auth-flow.js.map +1 -0
- package/dist/auth.d.ts +42 -0
- package/dist/auth.d.ts.map +1 -0
- package/dist/auth.js +117 -0
- package/dist/auth.js.map +1 -0
- package/dist/command-executor.d.ts +44 -0
- package/dist/command-executor.d.ts.map +1 -0
- package/dist/command-executor.js +193 -0
- package/dist/command-executor.js.map +1 -0
- package/dist/command-executor.test.d.ts +8 -0
- package/dist/command-executor.test.d.ts.map +1 -0
- package/dist/command-executor.test.js +87 -0
- package/dist/command-executor.test.js.map +1 -0
- package/dist/command-queue.d.ts +44 -0
- package/dist/command-queue.d.ts.map +1 -0
- package/dist/command-queue.js +184 -0
- package/dist/command-queue.js.map +1 -0
- package/dist/command-queue.test.d.ts +7 -0
- package/dist/command-queue.test.d.ts.map +1 -0
- package/dist/command-queue.test.js +220 -0
- package/dist/command-queue.test.js.map +1 -0
- package/dist/config.d.ts +25 -0
- package/dist/config.d.ts.map +1 -0
- package/dist/config.js +103 -0
- package/dist/config.js.map +1 -0
- package/dist/conflict-resolver.d.ts +43 -0
- package/dist/conflict-resolver.d.ts.map +1 -0
- package/dist/conflict-resolver.js +91 -0
- package/dist/conflict-resolver.js.map +1 -0
- package/dist/conflict-resolver.test.d.ts +7 -0
- package/dist/conflict-resolver.test.d.ts.map +1 -0
- package/dist/conflict-resolver.test.js +123 -0
- package/dist/conflict-resolver.test.js.map +1 -0
- package/dist/discovery.d.ts +59 -0
- package/dist/discovery.d.ts.map +1 -0
- package/dist/discovery.js +180 -0
- package/dist/discovery.js.map +1 -0
- package/dist/discovery.test.d.ts +8 -0
- package/dist/discovery.test.d.ts.map +1 -0
- package/dist/discovery.test.js +132 -0
- package/dist/discovery.test.js.map +1 -0
- package/dist/hash.d.ts +20 -0
- package/dist/hash.d.ts.map +1 -0
- package/dist/hash.js +35 -0
- package/dist/hash.js.map +1 -0
- package/dist/hash.test.d.ts +7 -0
- package/dist/hash.test.d.ts.map +1 -0
- package/dist/hash.test.js +58 -0
- package/dist/hash.test.js.map +1 -0
- package/dist/index.d.ts +11 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +202 -0
- package/dist/index.js.map +1 -0
- package/dist/integration.test.d.ts +8 -0
- package/dist/integration.test.d.ts.map +1 -0
- package/dist/integration.test.js +37 -0
- package/dist/integration.test.js.map +1 -0
- package/dist/logger.d.ts +68 -0
- package/dist/logger.d.ts.map +1 -0
- package/dist/logger.js +159 -0
- package/dist/logger.js.map +1 -0
- package/dist/output-streamer.d.ts +27 -0
- package/dist/output-streamer.d.ts.map +1 -0
- package/dist/output-streamer.js +71 -0
- package/dist/output-streamer.js.map +1 -0
- package/dist/output-streamer.test.d.ts +7 -0
- package/dist/output-streamer.test.d.ts.map +1 -0
- package/dist/output-streamer.test.js +90 -0
- package/dist/output-streamer.test.js.map +1 -0
- package/dist/realtime-subscriber.d.ts +63 -0
- package/dist/realtime-subscriber.d.ts.map +1 -0
- package/dist/realtime-subscriber.js +201 -0
- package/dist/realtime-subscriber.js.map +1 -0
- package/dist/realtime-subscriber.test.d.ts +7 -0
- package/dist/realtime-subscriber.test.d.ts.map +1 -0
- package/dist/realtime-subscriber.test.js +183 -0
- package/dist/realtime-subscriber.test.js.map +1 -0
- package/dist/reconnection-manager.d.ts +88 -0
- package/dist/reconnection-manager.d.ts.map +1 -0
- package/dist/reconnection-manager.js +229 -0
- package/dist/reconnection-manager.js.map +1 -0
- package/dist/reconnection-manager.test.d.ts +8 -0
- package/dist/reconnection-manager.test.d.ts.map +1 -0
- package/dist/reconnection-manager.test.js +151 -0
- package/dist/reconnection-manager.test.js.map +1 -0
- package/dist/remote-sync-handler.d.ts +61 -0
- package/dist/remote-sync-handler.d.ts.map +1 -0
- package/dist/remote-sync-handler.js +197 -0
- package/dist/remote-sync-handler.js.map +1 -0
- package/dist/remote-sync-handler.test.d.ts +7 -0
- package/dist/remote-sync-handler.test.d.ts.map +1 -0
- package/dist/remote-sync-handler.test.js +212 -0
- package/dist/remote-sync-handler.test.js.map +1 -0
- package/dist/retry.d.ts +35 -0
- package/dist/retry.d.ts.map +1 -0
- package/dist/retry.js +63 -0
- package/dist/retry.js.map +1 -0
- package/dist/retry.test.d.ts +5 -0
- package/dist/retry.test.d.ts.map +1 -0
- package/dist/retry.test.js +84 -0
- package/dist/retry.test.js.map +1 -0
- package/dist/storage-client.d.ts +69 -0
- package/dist/storage-client.d.ts.map +1 -0
- package/dist/storage-client.js +168 -0
- package/dist/storage-client.js.map +1 -0
- package/dist/storage-client.test.d.ts +7 -0
- package/dist/storage-client.test.d.ts.map +1 -0
- package/dist/storage-client.test.js +126 -0
- package/dist/storage-client.test.js.map +1 -0
- package/dist/supabase.d.ts +82 -0
- package/dist/supabase.d.ts.map +1 -0
- package/dist/supabase.js +341 -0
- package/dist/supabase.js.map +1 -0
- package/dist/supabase.test.d.ts +7 -0
- package/dist/supabase.test.d.ts.map +1 -0
- package/dist/supabase.test.js +273 -0
- package/dist/supabase.test.js.map +1 -0
- package/dist/sync-engine.d.ts +84 -0
- package/dist/sync-engine.d.ts.map +1 -0
- package/dist/sync-engine.js +251 -0
- package/dist/sync-engine.js.map +1 -0
- package/dist/sync-engine.test.d.ts +7 -0
- package/dist/sync-engine.test.d.ts.map +1 -0
- package/dist/sync-engine.test.js +241 -0
- package/dist/sync-engine.test.js.map +1 -0
- package/dist/sync-state.d.ts +82 -0
- package/dist/sync-state.d.ts.map +1 -0
- package/dist/sync-state.js +145 -0
- package/dist/sync-state.js.map +1 -0
- package/dist/sync-state.test.d.ts +7 -0
- package/dist/sync-state.test.d.ts.map +1 -0
- package/dist/sync-state.test.js +129 -0
- package/dist/sync-state.test.js.map +1 -0
- package/dist/types.d.ts +148 -0
- package/dist/types.d.ts.map +1 -0
- package/dist/types.js +8 -0
- package/dist/types.js.map +1 -0
- package/dist/types.test.d.ts +7 -0
- package/dist/types.test.d.ts.map +1 -0
- package/dist/types.test.js +73 -0
- package/dist/types.test.js.map +1 -0
- package/dist/watcher.d.ts +55 -0
- package/dist/watcher.d.ts.map +1 -0
- package/dist/watcher.js +214 -0
- package/dist/watcher.js.map +1 -0
- package/dist/watcher.test.d.ts +8 -0
- package/dist/watcher.test.d.ts.map +1 -0
- package/dist/watcher.test.js +164 -0
- package/dist/watcher.test.js.map +1 -0
- package/package.json +58 -0
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tests for ReconnectionManager
|
|
3
|
+
*
|
|
4
|
+
* Validates disconnection detection, exponential backoff reconnection,
|
|
5
|
+
* and bidirectional reconciliation after reconnect.
|
|
6
|
+
*/
|
|
7
|
+
import { describe, it, expect, beforeEach, vi } from 'vitest';
|
|
8
|
+
import { createReconnectionManager } from './reconnection-manager.js';
|
|
9
|
+
import { createLogger } from './logger.js';
|
|
10
|
+
describe('ReconnectionManager', () => {
|
|
11
|
+
let mockSupabase;
|
|
12
|
+
let mockSubscriber;
|
|
13
|
+
let mockRemoteSyncHandler;
|
|
14
|
+
let mockSyncEngine;
|
|
15
|
+
let mockSyncStateManager;
|
|
16
|
+
let workspaces;
|
|
17
|
+
let logger;
|
|
18
|
+
beforeEach(() => {
|
|
19
|
+
// Mock Supabase client
|
|
20
|
+
mockSupabase = {
|
|
21
|
+
connect: vi.fn().mockResolvedValue({ success: true })
|
|
22
|
+
};
|
|
23
|
+
// Mock RealtimeSubscriber
|
|
24
|
+
mockSubscriber = {
|
|
25
|
+
subscribe: vi.fn()
|
|
26
|
+
};
|
|
27
|
+
// Mock RemoteSyncHandler
|
|
28
|
+
mockRemoteSyncHandler = {
|
|
29
|
+
handleRemoteChange: vi.fn()
|
|
30
|
+
};
|
|
31
|
+
// Mock SyncEngine
|
|
32
|
+
mockSyncEngine = {
|
|
33
|
+
getState: vi.fn().mockReturnValue({
|
|
34
|
+
sync_queue: []
|
|
35
|
+
})
|
|
36
|
+
};
|
|
37
|
+
// Mock SyncStateManager
|
|
38
|
+
mockSyncStateManager = {
|
|
39
|
+
getWorkspaceState: vi.fn().mockReturnValue({
|
|
40
|
+
workspace_id: 'workspace-123',
|
|
41
|
+
last_sync_timestamp: '2026-03-27T00:00:00Z',
|
|
42
|
+
connection_status: 'connected',
|
|
43
|
+
pending_changes_count: 0
|
|
44
|
+
}),
|
|
45
|
+
updateWorkspaceState: vi.fn()
|
|
46
|
+
};
|
|
47
|
+
// Create test workspace
|
|
48
|
+
workspaces = new Map([
|
|
49
|
+
[
|
|
50
|
+
'workspace-123',
|
|
51
|
+
{
|
|
52
|
+
id: 'workspace-123',
|
|
53
|
+
root_path: '/test/workspace',
|
|
54
|
+
name: 'Test Workspace',
|
|
55
|
+
status: 'active',
|
|
56
|
+
last_sync: '2026-03-27T00:00:00Z',
|
|
57
|
+
discovered_at: '2026-03-27T00:00:00Z'
|
|
58
|
+
}
|
|
59
|
+
]
|
|
60
|
+
]);
|
|
61
|
+
logger = createLogger({
|
|
62
|
+
log_level: 'ERROR',
|
|
63
|
+
log_file: '/tmp/test.log',
|
|
64
|
+
log_rotation_days: 1
|
|
65
|
+
});
|
|
66
|
+
});
|
|
67
|
+
it('Test 1: detectDisconnection() sets connection_status to disconnected', () => {
|
|
68
|
+
const manager = createReconnectionManager(mockSupabase, mockSubscriber, mockRemoteSyncHandler, mockSyncEngine, mockSyncStateManager, workspaces, logger);
|
|
69
|
+
manager.detectDisconnection('workspace-123');
|
|
70
|
+
expect(mockSyncStateManager.updateWorkspaceState).toHaveBeenCalledWith('workspace-123', expect.objectContaining({
|
|
71
|
+
connection_status: 'disconnected'
|
|
72
|
+
}));
|
|
73
|
+
});
|
|
74
|
+
it('Test 2: attemptReconnection() retries with exponential backoff (1s, 2s, 4s, 8s, max 60s)', async () => {
|
|
75
|
+
vi.useFakeTimers();
|
|
76
|
+
// Mock connect to fail first 3 times, then succeed
|
|
77
|
+
let callCount = 0;
|
|
78
|
+
mockSupabase.connect = vi.fn().mockImplementation(() => {
|
|
79
|
+
callCount++;
|
|
80
|
+
if (callCount <= 3) {
|
|
81
|
+
return Promise.resolve({ success: false, error: 'Connection failed' });
|
|
82
|
+
}
|
|
83
|
+
return Promise.resolve({ success: true });
|
|
84
|
+
});
|
|
85
|
+
const manager = createReconnectionManager(mockSupabase, mockSubscriber, mockRemoteSyncHandler, mockSyncEngine, mockSyncStateManager, workspaces, logger);
|
|
86
|
+
// Start reconnection attempt
|
|
87
|
+
const reconnectPromise = manager.attemptReconnection('workspace-123');
|
|
88
|
+
// Fast-forward through backoff delays: 1s, 2s, 4s
|
|
89
|
+
await vi.advanceTimersByTimeAsync(1000); // First retry after 1s
|
|
90
|
+
await vi.advanceTimersByTimeAsync(2000); // Second retry after 2s
|
|
91
|
+
await vi.advanceTimersByTimeAsync(4000); // Third retry after 4s
|
|
92
|
+
await reconnectPromise;
|
|
93
|
+
expect(mockSupabase.connect).toHaveBeenCalledTimes(4); // Initial + 3 retries
|
|
94
|
+
expect(mockSyncStateManager.updateWorkspaceState).toHaveBeenCalledWith('workspace-123', expect.objectContaining({
|
|
95
|
+
connection_status: 'connected'
|
|
96
|
+
}));
|
|
97
|
+
vi.useRealTimers();
|
|
98
|
+
});
|
|
99
|
+
it('Test 3: On successful reconnect, reconcileChanges() syncs local → remote', async () => {
|
|
100
|
+
const manager = createReconnectionManager(mockSupabase, mockSubscriber, mockRemoteSyncHandler, mockSyncEngine, mockSyncStateManager, workspaces, logger);
|
|
101
|
+
// Mock sync engine with pending changes
|
|
102
|
+
mockSyncEngine.getState = vi.fn().mockReturnValue({
|
|
103
|
+
sync_queue: [
|
|
104
|
+
{
|
|
105
|
+
id: 'event-1',
|
|
106
|
+
workspace_id: 'workspace-123',
|
|
107
|
+
file_path: '.planning/STATE.md',
|
|
108
|
+
content: 'local content',
|
|
109
|
+
content_hash: 'hash123'
|
|
110
|
+
}
|
|
111
|
+
]
|
|
112
|
+
});
|
|
113
|
+
await manager.reconcileChanges('workspace-123');
|
|
114
|
+
// Verify local changes were synced
|
|
115
|
+
expect(mockSyncEngine.getState).toHaveBeenCalled();
|
|
116
|
+
});
|
|
117
|
+
it('Test 4: On successful reconnect, reconcileChanges() syncs remote → local', async () => {
|
|
118
|
+
const manager = createReconnectionManager(mockSupabase, mockSubscriber, mockRemoteSyncHandler, mockSyncEngine, mockSyncStateManager, workspaces, logger);
|
|
119
|
+
await manager.reconcileChanges('workspace-123');
|
|
120
|
+
// Verify reconciliation was attempted
|
|
121
|
+
expect(mockSyncStateManager.getWorkspaceState).toHaveBeenCalledWith('workspace-123');
|
|
122
|
+
});
|
|
123
|
+
it('Test 5: reconcileChanges() uses last_sync_timestamp to fetch missed changes', async () => {
|
|
124
|
+
const manager = createReconnectionManager(mockSupabase, mockSubscriber, mockRemoteSyncHandler, mockSyncEngine, mockSyncStateManager, workspaces, logger);
|
|
125
|
+
mockSyncStateManager.getWorkspaceState = vi.fn().mockReturnValue({
|
|
126
|
+
workspace_id: 'workspace-123',
|
|
127
|
+
last_sync_timestamp: '2026-03-27T01:00:00Z',
|
|
128
|
+
connection_status: 'disconnected',
|
|
129
|
+
pending_changes_count: 0
|
|
130
|
+
});
|
|
131
|
+
await manager.reconcileChanges('workspace-123');
|
|
132
|
+
expect(mockSyncStateManager.getWorkspaceState).toHaveBeenCalledWith('workspace-123');
|
|
133
|
+
});
|
|
134
|
+
it('Test 6: reconcileChanges() detects conflicts during bidirectional sync', async () => {
|
|
135
|
+
const manager = createReconnectionManager(mockSupabase, mockSubscriber, mockRemoteSyncHandler, mockSyncEngine, mockSyncStateManager, workspaces, logger);
|
|
136
|
+
// This test verifies the conflict detection logic is invoked
|
|
137
|
+
await manager.reconcileChanges('workspace-123');
|
|
138
|
+
// Verify state was checked for reconciliation
|
|
139
|
+
expect(mockSyncStateManager.getWorkspaceState).toHaveBeenCalled();
|
|
140
|
+
});
|
|
141
|
+
it('Test 7: Logs recovery summary: "Synced N local changes, M remote changes, K conflicts"', async () => {
|
|
142
|
+
const logSpy = vi.spyOn(logger, 'info');
|
|
143
|
+
const manager = createReconnectionManager(mockSupabase, mockSubscriber, mockRemoteSyncHandler, mockSyncEngine, mockSyncStateManager, workspaces, logger);
|
|
144
|
+
await manager.reconcileChanges('workspace-123');
|
|
145
|
+
// Verify recovery summary was logged
|
|
146
|
+
expect(logSpy).toHaveBeenCalledWith(expect.stringContaining('Reconciliation complete'), expect.objectContaining({
|
|
147
|
+
workspace_id: 'workspace-123'
|
|
148
|
+
}));
|
|
149
|
+
});
|
|
150
|
+
});
|
|
151
|
+
//# sourceMappingURL=reconnection-manager.test.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"reconnection-manager.test.js","sourceRoot":"","sources":["../src/reconnection-manager.test.ts"],"names":[],"mappings":"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"}
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Remote sync handler for GSD Agent
|
|
3
|
+
*
|
|
4
|
+
* Handles remote → local sync by listening to RealtimeSubscriber events
|
|
5
|
+
* and writing changes to the filesystem with conflict detection.
|
|
6
|
+
*/
|
|
7
|
+
import type { RemoteChangeEvent, Workspace } from './types.js';
|
|
8
|
+
import type { RealtimeSubscriber } from './realtime-subscriber.js';
|
|
9
|
+
import type { ConflictResolver } from './conflict-resolver.js';
|
|
10
|
+
import type { Logger } from './logger.js';
|
|
11
|
+
/**
|
|
12
|
+
* RemoteSyncHandler applies remote changes to local filesystem
|
|
13
|
+
*
|
|
14
|
+
* Subscribes to RealtimeSubscriber events and writes remote changes locally.
|
|
15
|
+
* Detects conflicts and creates git-style conflict markers when needed.
|
|
16
|
+
* Prevents sync loops by tracking in-progress syncs.
|
|
17
|
+
*/
|
|
18
|
+
export declare class RemoteSyncHandler {
|
|
19
|
+
private subscriber;
|
|
20
|
+
private resolver;
|
|
21
|
+
private workspaces;
|
|
22
|
+
private logger;
|
|
23
|
+
private syncingFiles;
|
|
24
|
+
private remoteChangeHandler;
|
|
25
|
+
constructor(subscriber: RealtimeSubscriber, resolver: ConflictResolver, workspaces: Map<string, Workspace>, logger: Logger);
|
|
26
|
+
/**
|
|
27
|
+
* Start listening to remote change events
|
|
28
|
+
*/
|
|
29
|
+
start(): void;
|
|
30
|
+
/**
|
|
31
|
+
* Stop listening to remote change events
|
|
32
|
+
*/
|
|
33
|
+
stop(): void;
|
|
34
|
+
/**
|
|
35
|
+
* Handle remote change event
|
|
36
|
+
* @param event - RemoteChangeEvent from RealtimeSubscriber
|
|
37
|
+
*/
|
|
38
|
+
handleRemoteChange(event: RemoteChangeEvent): Promise<void>;
|
|
39
|
+
/**
|
|
40
|
+
* Handle large file download from Supabase Storage
|
|
41
|
+
* @param event - RemoteChangeEvent with storage_url
|
|
42
|
+
* @param absolutePath - Absolute path to write file
|
|
43
|
+
*/
|
|
44
|
+
private handleLargeFile;
|
|
45
|
+
/**
|
|
46
|
+
* Write file atomically with parent directory creation
|
|
47
|
+
* @param filePath - Absolute path to file
|
|
48
|
+
* @param content - File content
|
|
49
|
+
*/
|
|
50
|
+
private writeFileAtomic;
|
|
51
|
+
}
|
|
52
|
+
/**
|
|
53
|
+
* Factory function to create RemoteSyncHandler
|
|
54
|
+
* @param subscriber - RealtimeSubscriber instance
|
|
55
|
+
* @param resolver - ConflictResolver instance
|
|
56
|
+
* @param workspaces - Map of workspace ID to Workspace
|
|
57
|
+
* @param logger - Logger instance
|
|
58
|
+
* @returns RemoteSyncHandler instance
|
|
59
|
+
*/
|
|
60
|
+
export declare function createRemoteSyncHandler(subscriber: RealtimeSubscriber, resolver: ConflictResolver, workspaces: Map<string, Workspace>, logger: Logger): RemoteSyncHandler;
|
|
61
|
+
//# sourceMappingURL=remote-sync-handler.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"remote-sync-handler.d.ts","sourceRoot":"","sources":["../src/remote-sync-handler.ts"],"names":[],"mappings":"AAAA;;;;;GAKG;AAIH,OAAO,KAAK,EAAE,iBAAiB,EAAE,SAAS,EAAE,MAAM,YAAY,CAAA;AAC9D,OAAO,KAAK,EAAE,kBAAkB,EAAE,MAAM,0BAA0B,CAAA;AAClE,OAAO,KAAK,EAAE,gBAAgB,EAAE,MAAM,wBAAwB,CAAA;AAC9D,OAAO,KAAK,EAAE,MAAM,EAAE,MAAM,aAAa,CAAA;AAEzC;;;;;;GAMG;AACH,qBAAa,iBAAiB;IAC5B,OAAO,CAAC,UAAU,CAAoB;IACtC,OAAO,CAAC,QAAQ,CAAkB;IAClC,OAAO,CAAC,UAAU,CAAwB;IAC1C,OAAO,CAAC,MAAM,CAAQ;IACtB,OAAO,CAAC,YAAY,CAAa;IACjC,OAAO,CAAC,mBAAmB,CAAsD;gBAG/E,UAAU,EAAE,kBAAkB,EAC9B,QAAQ,EAAE,gBAAgB,EAC1B,UAAU,EAAE,GAAG,CAAC,MAAM,EAAE,SAAS,CAAC,EAClC,MAAM,EAAE,MAAM;IAUhB;;OAEG;IACH,KAAK,IAAI,IAAI;IAYb;;OAEG;IACH,IAAI,IAAI,IAAI;IAYZ;;;OAGG;IACG,kBAAkB,CAAC,KAAK,EAAE,iBAAiB,GAAG,OAAO,CAAC,IAAI,CAAC;IAgGjE;;;;OAIG;YACW,eAAe;IAmC7B;;;;OAIG;IACH,OAAO,CAAC,eAAe;CAUxB;AAED;;;;;;;GAOG;AACH,wBAAgB,uBAAuB,CACrC,UAAU,EAAE,kBAAkB,EAC9B,QAAQ,EAAE,gBAAgB,EAC1B,UAAU,EAAE,GAAG,CAAC,MAAM,EAAE,SAAS,CAAC,EAClC,MAAM,EAAE,MAAM,GACb,iBAAiB,CAEnB"}
|
|
@@ -0,0 +1,197 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Remote sync handler for GSD Agent
|
|
3
|
+
*
|
|
4
|
+
* Handles remote → local sync by listening to RealtimeSubscriber events
|
|
5
|
+
* and writing changes to the filesystem with conflict detection.
|
|
6
|
+
*/
|
|
7
|
+
import fs from 'fs';
|
|
8
|
+
import path from 'path';
|
|
9
|
+
/**
|
|
10
|
+
* RemoteSyncHandler applies remote changes to local filesystem
|
|
11
|
+
*
|
|
12
|
+
* Subscribes to RealtimeSubscriber events and writes remote changes locally.
|
|
13
|
+
* Detects conflicts and creates git-style conflict markers when needed.
|
|
14
|
+
* Prevents sync loops by tracking in-progress syncs.
|
|
15
|
+
*/
|
|
16
|
+
export class RemoteSyncHandler {
|
|
17
|
+
subscriber;
|
|
18
|
+
resolver;
|
|
19
|
+
workspaces;
|
|
20
|
+
logger;
|
|
21
|
+
syncingFiles;
|
|
22
|
+
remoteChangeHandler;
|
|
23
|
+
constructor(subscriber, resolver, workspaces, logger) {
|
|
24
|
+
this.subscriber = subscriber;
|
|
25
|
+
this.resolver = resolver;
|
|
26
|
+
this.workspaces = workspaces;
|
|
27
|
+
this.logger = logger;
|
|
28
|
+
this.syncingFiles = new Set();
|
|
29
|
+
this.remoteChangeHandler = null;
|
|
30
|
+
}
|
|
31
|
+
/**
|
|
32
|
+
* Start listening to remote change events
|
|
33
|
+
*/
|
|
34
|
+
start() {
|
|
35
|
+
this.logger.info('Remote sync handler started', {
|
|
36
|
+
workspace_count: this.workspaces.size
|
|
37
|
+
});
|
|
38
|
+
// Create bound handler
|
|
39
|
+
this.remoteChangeHandler = this.handleRemoteChange.bind(this);
|
|
40
|
+
// Subscribe to remote-change events
|
|
41
|
+
this.subscriber.on('remote-change', this.remoteChangeHandler);
|
|
42
|
+
}
|
|
43
|
+
/**
|
|
44
|
+
* Stop listening to remote change events
|
|
45
|
+
*/
|
|
46
|
+
stop() {
|
|
47
|
+
this.logger.info('Remote sync handler stopped', {
|
|
48
|
+
workspace_count: this.workspaces.size
|
|
49
|
+
});
|
|
50
|
+
// Unsubscribe from events
|
|
51
|
+
if (this.remoteChangeHandler) {
|
|
52
|
+
this.subscriber.off('remote-change', this.remoteChangeHandler);
|
|
53
|
+
this.remoteChangeHandler = null;
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
/**
|
|
57
|
+
* Handle remote change event
|
|
58
|
+
* @param event - RemoteChangeEvent from RealtimeSubscriber
|
|
59
|
+
*/
|
|
60
|
+
async handleRemoteChange(event) {
|
|
61
|
+
const syncKey = `${event.workspace_id}:${event.file_path}`;
|
|
62
|
+
// Prevent sync loops - skip if already syncing this file
|
|
63
|
+
if (this.syncingFiles.has(syncKey)) {
|
|
64
|
+
this.logger.debug('Skipping remote change - sync already in progress', {
|
|
65
|
+
workspace_id: event.workspace_id,
|
|
66
|
+
file_path: event.file_path
|
|
67
|
+
});
|
|
68
|
+
return;
|
|
69
|
+
}
|
|
70
|
+
try {
|
|
71
|
+
this.syncingFiles.add(syncKey);
|
|
72
|
+
// Get workspace
|
|
73
|
+
const workspace = this.workspaces.get(event.workspace_id);
|
|
74
|
+
if (!workspace) {
|
|
75
|
+
this.logger.warn('Workspace not found for remote change', {
|
|
76
|
+
workspace_id: event.workspace_id
|
|
77
|
+
});
|
|
78
|
+
return;
|
|
79
|
+
}
|
|
80
|
+
// Build absolute file path
|
|
81
|
+
const absolutePath = path.join(workspace.root_path, event.file_path);
|
|
82
|
+
// Handle large files from Storage
|
|
83
|
+
if (event.storage_url && !event.content) {
|
|
84
|
+
await this.handleLargeFile(event, absolutePath);
|
|
85
|
+
return;
|
|
86
|
+
}
|
|
87
|
+
// Check if file exists locally
|
|
88
|
+
const fileExists = fs.existsSync(absolutePath);
|
|
89
|
+
if (fileExists) {
|
|
90
|
+
// Read local content
|
|
91
|
+
const localContent = fs.readFileSync(absolutePath, 'utf8');
|
|
92
|
+
// Detect conflict
|
|
93
|
+
const conflict = await this.subscriber.detectConflict(event.workspace_id, event.file_path, event.content_hash, event.content || '', localContent);
|
|
94
|
+
if (conflict) {
|
|
95
|
+
// Conflict detected - create conflict markers
|
|
96
|
+
this.logger.warn('Conflict detected - creating conflict markers', {
|
|
97
|
+
workspace_id: event.workspace_id,
|
|
98
|
+
file_path: event.file_path,
|
|
99
|
+
local_hash: conflict.local_hash,
|
|
100
|
+
remote_hash: conflict.remote_hash
|
|
101
|
+
});
|
|
102
|
+
const conflictMarkers = this.resolver.resolveConflict(conflict);
|
|
103
|
+
// Write conflict markers to file
|
|
104
|
+
this.writeFileAtomic(absolutePath, conflictMarkers);
|
|
105
|
+
// Commit conflicted file
|
|
106
|
+
await this.resolver.commitConflict(workspace.root_path, event.file_path);
|
|
107
|
+
this.logger.info('Conflict resolved with markers and committed', {
|
|
108
|
+
workspace_id: event.workspace_id,
|
|
109
|
+
file_path: event.file_path
|
|
110
|
+
});
|
|
111
|
+
return;
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
// No conflict or file doesn't exist - write remote content
|
|
115
|
+
if (event.content !== null) {
|
|
116
|
+
this.writeFileAtomic(absolutePath, event.content);
|
|
117
|
+
this.logger.info('File synced from remote', {
|
|
118
|
+
workspace_id: event.workspace_id,
|
|
119
|
+
file_path: event.file_path,
|
|
120
|
+
size: event.size
|
|
121
|
+
});
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
catch (error) {
|
|
125
|
+
this.logger.error('Error handling remote change', {
|
|
126
|
+
workspace_id: event.workspace_id,
|
|
127
|
+
file_path: event.file_path,
|
|
128
|
+
error: error instanceof Error ? error.message : String(error)
|
|
129
|
+
});
|
|
130
|
+
}
|
|
131
|
+
finally {
|
|
132
|
+
this.syncingFiles.delete(syncKey);
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
/**
|
|
136
|
+
* Handle large file download from Supabase Storage
|
|
137
|
+
* @param event - RemoteChangeEvent with storage_url
|
|
138
|
+
* @param absolutePath - Absolute path to write file
|
|
139
|
+
*/
|
|
140
|
+
async handleLargeFile(event, absolutePath) {
|
|
141
|
+
try {
|
|
142
|
+
this.logger.info('Downloading large file from Storage', {
|
|
143
|
+
workspace_id: event.workspace_id,
|
|
144
|
+
file_path: event.file_path,
|
|
145
|
+
storage_url: event.storage_url,
|
|
146
|
+
size: event.size
|
|
147
|
+
});
|
|
148
|
+
// Download from Storage
|
|
149
|
+
const response = await fetch(event.storage_url);
|
|
150
|
+
if (!response.ok) {
|
|
151
|
+
throw new Error(`Storage download failed: ${response.statusText}`);
|
|
152
|
+
}
|
|
153
|
+
const content = await response.text();
|
|
154
|
+
// Write to filesystem
|
|
155
|
+
this.writeFileAtomic(absolutePath, content);
|
|
156
|
+
this.logger.info('Large file downloaded and synced', {
|
|
157
|
+
workspace_id: event.workspace_id,
|
|
158
|
+
file_path: event.file_path,
|
|
159
|
+
size: content.length
|
|
160
|
+
});
|
|
161
|
+
}
|
|
162
|
+
catch (error) {
|
|
163
|
+
this.logger.error('Error downloading large file', {
|
|
164
|
+
workspace_id: event.workspace_id,
|
|
165
|
+
file_path: event.file_path,
|
|
166
|
+
storage_url: event.storage_url,
|
|
167
|
+
error: error instanceof Error ? error.message : String(error)
|
|
168
|
+
});
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
/**
|
|
172
|
+
* Write file atomically with parent directory creation
|
|
173
|
+
* @param filePath - Absolute path to file
|
|
174
|
+
* @param content - File content
|
|
175
|
+
*/
|
|
176
|
+
writeFileAtomic(filePath, content) {
|
|
177
|
+
// Create parent directories if needed
|
|
178
|
+
const dir = path.dirname(filePath);
|
|
179
|
+
if (!fs.existsSync(dir)) {
|
|
180
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
181
|
+
}
|
|
182
|
+
// Write file atomically
|
|
183
|
+
fs.writeFileSync(filePath, content, 'utf8');
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
/**
|
|
187
|
+
* Factory function to create RemoteSyncHandler
|
|
188
|
+
* @param subscriber - RealtimeSubscriber instance
|
|
189
|
+
* @param resolver - ConflictResolver instance
|
|
190
|
+
* @param workspaces - Map of workspace ID to Workspace
|
|
191
|
+
* @param logger - Logger instance
|
|
192
|
+
* @returns RemoteSyncHandler instance
|
|
193
|
+
*/
|
|
194
|
+
export function createRemoteSyncHandler(subscriber, resolver, workspaces, logger) {
|
|
195
|
+
return new RemoteSyncHandler(subscriber, resolver, workspaces, logger);
|
|
196
|
+
}
|
|
197
|
+
//# sourceMappingURL=remote-sync-handler.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"remote-sync-handler.js","sourceRoot":"","sources":["../src/remote-sync-handler.ts"],"names":[],"mappings":"AAAA;;;;;GAKG;AAEH,OAAO,EAAE,MAAM,IAAI,CAAA;AACnB,OAAO,IAAI,MAAM,MAAM,CAAA;AAMvB;;;;;;GAMG;AACH,MAAM,OAAO,iBAAiB;IACpB,UAAU,CAAoB;IAC9B,QAAQ,CAAkB;IAC1B,UAAU,CAAwB;IAClC,MAAM,CAAQ;IACd,YAAY,CAAa;IACzB,mBAAmB,CAAsD;IAEjF,YACE,UAA8B,EAC9B,QAA0B,EAC1B,UAAkC,EAClC,MAAc;QAEd,IAAI,CAAC,UAAU,GAAG,UAAU,CAAA;QAC5B,IAAI,CAAC,QAAQ,GAAG,QAAQ,CAAA;QACxB,IAAI,CAAC,UAAU,GAAG,UAAU,CAAA;QAC5B,IAAI,CAAC,MAAM,GAAG,MAAM,CAAA;QACpB,IAAI,CAAC,YAAY,GAAG,IAAI,GAAG,EAAE,CAAA;QAC7B,IAAI,CAAC,mBAAmB,GAAG,IAAI,CAAA;IACjC,CAAC;IAED;;OAEG;IACH,KAAK;QACH,IAAI,CAAC,MAAM,CAAC,IAAI,CAAC,6BAA6B,EAAE;YAC9C,eAAe,EAAE,IAAI,CAAC,UAAU,CAAC,IAAI;SACtC,CAAC,CAAA;QAEF,uBAAuB;QACvB,IAAI,CAAC,mBAAmB,GAAG,IAAI,CAAC,kBAAkB,CAAC,IAAI,CAAC,IAAI,CAAC,CAAA;QAE7D,oCAAoC;QACpC,IAAI,CAAC,UAAU,CAAC,EAAE,CAAC,eAAe,EAAE,IAAI,CAAC,mBAAmB,CAAC,CAAA;IAC/D,CAAC;IAED;;OAEG;IACH,IAAI;QACF,IAAI,CAAC,MAAM,CAAC,IAAI,CAAC,6BAA6B,EAAE;YAC9C,eAAe,EAAE,IAAI,CAAC,UAAU,CAAC,IAAI;SACtC,CAAC,CAAA;QAEF,0BAA0B;QAC1B,IAAI,IAAI,CAAC,mBAAmB,EAAE,CAAC;YAC7B,IAAI,CAAC,UAAU,CAAC,GAAG,CAAC,eAAe,EAAE,IAAI,CAAC,mBAAmB,CAAC,CAAA;YAC9D,IAAI,CAAC,mBAAmB,GAAG,IAAI,CAAA;QACjC,CAAC;IACH,CAAC;IAED;;;OAGG;IACH,KAAK,CAAC,kBAAkB,CAAC,KAAwB;QAC/C,MAAM,OAAO,GAAG,GAAG,KAAK,CAAC,YAAY,IAAI,KAAK,CAAC,SAAS,EAAE,CAAA;QAE1D,yDAAyD;QACzD,IAAI,IAAI,CAAC,YAAY,CAAC,GAAG,CAAC,OAAO,CAAC,EAAE,CAAC;YACnC,IAAI,CAAC,MAAM,CAAC,KAAK,CAAC,mDAAmD,EAAE;gBACrE,YAAY,EAAE,KAAK,CAAC,YAAY;gBAChC,SAAS,EAAE,KAAK,CAAC,SAAS;aAC3B,CAAC,CAAA;YACF,OAAM;QACR,CAAC;QAED,IAAI,CAAC;YACH,IAAI,CAAC,YAAY,CAAC,GAAG,CAAC,OAAO,CAAC,CAAA;YAE9B,gBAAgB;YAChB,MAAM,SAAS,GAAG,IAAI,CAAC,UAAU,CAAC,GAAG,CAAC,KAAK,CAAC,YAAY,CAAC,CAAA;YACzD,IAAI,CAAC,SAAS,EAAE,CAAC;gBACf,IAAI,CAAC,MAAM,CAAC,IAAI,CAAC,uCAAuC,EAAE;oBACxD,YAAY,EAAE,KAAK,CAAC,YAAY;iBACjC,CAAC,CAAA;gBACF,OAAM;YACR,CAAC;YAED,2BAA2B;YAC3B,MAAM,YAAY,GAAG,IAAI,CAAC,IAAI,CAAC,SAAS,CAAC,SAAS,EAAE,KAAK,CAAC,SAAS,CAAC,CAAA;YAEpE,kCAAkC;YAClC,IAAI,KAAK,CAAC,WAAW,IAAI,CAAC,KAAK,CAAC,OAAO,EAAE,CAAC;gBACxC,MAAM,IAAI,CAAC,eAAe,CAAC,KAAK,EAAE,YAAY,CAAC,CAAA;gBAC/C,OAAM;YACR,CAAC;YAED,+BAA+B;YAC/B,MAAM,UAAU,GAAG,EAAE,CAAC,UAAU,CAAC,YAAY,CAAC,CAAA;YAE9C,IAAI,UAAU,EAAE,CAAC;gBACf,qBAAqB;gBACrB,MAAM,YAAY,GAAG,EAAE,CAAC,YAAY,CAAC,YAAY,EAAE,MAAM,CAAC,CAAA;gBAE1D,kBAAkB;gBAClB,MAAM,QAAQ,GAAG,MAAM,IAAI,CAAC,UAAU,CAAC,cAAc,CACnD,KAAK,CAAC,YAAY,EAClB,KAAK,CAAC,SAAS,EACf,KAAK,CAAC,YAAY,EAClB,KAAK,CAAC,OAAO,IAAI,EAAE,EACnB,YAAY,CACb,CAAA;gBAED,IAAI,QAAQ,EAAE,CAAC;oBACb,8CAA8C;oBAC9C,IAAI,CAAC,MAAM,CAAC,IAAI,CAAC,+CAA+C,EAAE;wBAChE,YAAY,EAAE,KAAK,CAAC,YAAY;wBAChC,SAAS,EAAE,KAAK,CAAC,SAAS;wBAC1B,UAAU,EAAE,QAAQ,CAAC,UAAU;wBAC/B,WAAW,EAAE,QAAQ,CAAC,WAAW;qBAClC,CAAC,CAAA;oBAEF,MAAM,eAAe,GAAG,IAAI,CAAC,QAAQ,CAAC,eAAe,CAAC,QAAQ,CAAC,CAAA;oBAE/D,iCAAiC;oBACjC,IAAI,CAAC,eAAe,CAAC,YAAY,EAAE,eAAe,CAAC,CAAA;oBAEnD,yBAAyB;oBACzB,MAAM,IAAI,CAAC,QAAQ,CAAC,cAAc,CAAC,SAAS,CAAC,SAAS,EAAE,KAAK,CAAC,SAAS,CAAC,CAAA;oBAExE,IAAI,CAAC,MAAM,CAAC,IAAI,CAAC,8CAA8C,EAAE;wBAC/D,YAAY,EAAE,KAAK,CAAC,YAAY;wBAChC,SAAS,EAAE,KAAK,CAAC,SAAS;qBAC3B,CAAC,CAAA;oBAEF,OAAM;gBACR,CAAC;YACH,CAAC;YAED,2DAA2D;YAC3D,IAAI,KAAK,CAAC,OAAO,KAAK,IAAI,EAAE,CAAC;gBAC3B,IAAI,CAAC,eAAe,CAAC,YAAY,EAAE,KAAK,CAAC,OAAO,CAAC,CAAA;gBAEjD,IAAI,CAAC,MAAM,CAAC,IAAI,CAAC,yBAAyB,EAAE;oBAC1C,YAAY,EAAE,KAAK,CAAC,YAAY;oBAChC,SAAS,EAAE,KAAK,CAAC,SAAS;oBAC1B,IAAI,EAAE,KAAK,CAAC,IAAI;iBACjB,CAAC,CAAA;YACJ,CAAC;QACH,CAAC;QAAC,OAAO,KAAK,EAAE,CAAC;YACf,IAAI,CAAC,MAAM,CAAC,KAAK,CAAC,8BAA8B,EAAE;gBAChD,YAAY,EAAE,KAAK,CAAC,YAAY;gBAChC,SAAS,EAAE,KAAK,CAAC,SAAS;gBAC1B,KAAK,EAAE,KAAK,YAAY,KAAK,CAAC,CAAC,CAAC,KAAK,CAAC,OAAO,CAAC,CAAC,CAAC,MAAM,CAAC,KAAK,CAAC;aAC9D,CAAC,CAAA;QACJ,CAAC;gBAAS,CAAC;YACT,IAAI,CAAC,YAAY,CAAC,MAAM,CAAC,OAAO,CAAC,CAAA;QACnC,CAAC;IACH,CAAC;IAED;;;;OAIG;IACK,KAAK,CAAC,eAAe,CAAC,KAAwB,EAAE,YAAoB;QAC1E,IAAI,CAAC;YACH,IAAI,CAAC,MAAM,CAAC,IAAI,CAAC,qCAAqC,EAAE;gBACtD,YAAY,EAAE,KAAK,CAAC,YAAY;gBAChC,SAAS,EAAE,KAAK,CAAC,SAAS;gBAC1B,WAAW,EAAE,KAAK,CAAC,WAAW;gBAC9B,IAAI,EAAE,KAAK,CAAC,IAAI;aACjB,CAAC,CAAA;YAEF,wBAAwB;YACxB,MAAM,QAAQ,GAAG,MAAM,KAAK,CAAC,KAAK,CAAC,WAAY,CAAC,CAAA;YAChD,IAAI,CAAC,QAAQ,CAAC,EAAE,EAAE,CAAC;gBACjB,MAAM,IAAI,KAAK,CAAC,4BAA4B,QAAQ,CAAC,UAAU,EAAE,CAAC,CAAA;YACpE,CAAC;YAED,MAAM,OAAO,GAAG,MAAM,QAAQ,CAAC,IAAI,EAAE,CAAA;YAErC,sBAAsB;YACtB,IAAI,CAAC,eAAe,CAAC,YAAY,EAAE,OAAO,CAAC,CAAA;YAE3C,IAAI,CAAC,MAAM,CAAC,IAAI,CAAC,kCAAkC,EAAE;gBACnD,YAAY,EAAE,KAAK,CAAC,YAAY;gBAChC,SAAS,EAAE,KAAK,CAAC,SAAS;gBAC1B,IAAI,EAAE,OAAO,CAAC,MAAM;aACrB,CAAC,CAAA;QACJ,CAAC;QAAC,OAAO,KAAK,EAAE,CAAC;YACf,IAAI,CAAC,MAAM,CAAC,KAAK,CAAC,8BAA8B,EAAE;gBAChD,YAAY,EAAE,KAAK,CAAC,YAAY;gBAChC,SAAS,EAAE,KAAK,CAAC,SAAS;gBAC1B,WAAW,EAAE,KAAK,CAAC,WAAW;gBAC9B,KAAK,EAAE,KAAK,YAAY,KAAK,CAAC,CAAC,CAAC,KAAK,CAAC,OAAO,CAAC,CAAC,CAAC,MAAM,CAAC,KAAK,CAAC;aAC9D,CAAC,CAAA;QACJ,CAAC;IACH,CAAC;IAED;;;;OAIG;IACK,eAAe,CAAC,QAAgB,EAAE,OAAe;QACvD,sCAAsC;QACtC,MAAM,GAAG,GAAG,IAAI,CAAC,OAAO,CAAC,QAAQ,CAAC,CAAA;QAClC,IAAI,CAAC,EAAE,CAAC,UAAU,CAAC,GAAG,CAAC,EAAE,CAAC;YACxB,EAAE,CAAC,SAAS,CAAC,GAAG,EAAE,EAAE,SAAS,EAAE,IAAI,EAAE,CAAC,CAAA;QACxC,CAAC;QAED,wBAAwB;QACxB,EAAE,CAAC,aAAa,CAAC,QAAQ,EAAE,OAAO,EAAE,MAAM,CAAC,CAAA;IAC7C,CAAC;CACF;AAED;;;;;;;GAOG;AACH,MAAM,UAAU,uBAAuB,CACrC,UAA8B,EAC9B,QAA0B,EAC1B,UAAkC,EAClC,MAAc;IAEd,OAAO,IAAI,iBAAiB,CAAC,UAAU,EAAE,QAAQ,EAAE,UAAU,EAAE,MAAM,CAAC,CAAA;AACxE,CAAC"}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"remote-sync-handler.test.d.ts","sourceRoot":"","sources":["../src/remote-sync-handler.test.ts"],"names":[],"mappings":"AAAA;;;;GAIG"}
|
|
@@ -0,0 +1,212 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tests for RemoteSyncHandler
|
|
3
|
+
*
|
|
4
|
+
* Verifies remote → local sync with conflict detection and filesystem writes.
|
|
5
|
+
*/
|
|
6
|
+
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
|
7
|
+
import { EventEmitter } from 'events';
|
|
8
|
+
// Mock fs at top level
|
|
9
|
+
vi.mock('fs', () => ({
|
|
10
|
+
default: {
|
|
11
|
+
existsSync: vi.fn(),
|
|
12
|
+
readFileSync: vi.fn(),
|
|
13
|
+
writeFileSync: vi.fn(),
|
|
14
|
+
mkdirSync: vi.fn()
|
|
15
|
+
}
|
|
16
|
+
}));
|
|
17
|
+
// Mock fetch at top level
|
|
18
|
+
global.fetch = vi.fn();
|
|
19
|
+
import { RemoteSyncHandler } from './remote-sync-handler.js';
|
|
20
|
+
import fs from 'fs';
|
|
21
|
+
describe('RemoteSyncHandler', () => {
|
|
22
|
+
let mockSubscriber;
|
|
23
|
+
let mockResolver;
|
|
24
|
+
let mockLogger;
|
|
25
|
+
let workspaces;
|
|
26
|
+
let handler;
|
|
27
|
+
beforeEach(() => {
|
|
28
|
+
// Mock RealtimeSubscriber
|
|
29
|
+
mockSubscriber = new EventEmitter();
|
|
30
|
+
mockSubscriber.detectConflict = vi.fn();
|
|
31
|
+
// Mock ConflictResolver
|
|
32
|
+
mockResolver = {
|
|
33
|
+
resolveConflict: vi.fn(),
|
|
34
|
+
commitConflict: vi.fn()
|
|
35
|
+
};
|
|
36
|
+
// Mock Logger
|
|
37
|
+
mockLogger = {
|
|
38
|
+
info: vi.fn(),
|
|
39
|
+
warn: vi.fn(),
|
|
40
|
+
error: vi.fn(),
|
|
41
|
+
debug: vi.fn()
|
|
42
|
+
};
|
|
43
|
+
// Setup workspaces
|
|
44
|
+
workspaces = new Map();
|
|
45
|
+
workspaces.set('ws-123', {
|
|
46
|
+
id: 'ws-123',
|
|
47
|
+
root_path: '/home/user/projects/my-project',
|
|
48
|
+
name: 'my-project',
|
|
49
|
+
status: 'active',
|
|
50
|
+
last_sync: '2026-03-27T00:00:00Z',
|
|
51
|
+
discovered_at: '2026-03-27T00:00:00Z'
|
|
52
|
+
});
|
|
53
|
+
handler = new RemoteSyncHandler(mockSubscriber, mockResolver, workspaces, mockLogger);
|
|
54
|
+
// Clear all mocks
|
|
55
|
+
vi.clearAllMocks();
|
|
56
|
+
});
|
|
57
|
+
afterEach(() => {
|
|
58
|
+
vi.clearAllMocks();
|
|
59
|
+
});
|
|
60
|
+
it('should write remote content to filesystem when handleRemoteChange is called', async () => {
|
|
61
|
+
const event = {
|
|
62
|
+
id: 'file-1',
|
|
63
|
+
workspace_id: 'ws-123',
|
|
64
|
+
file_path: '.planning/STATE.md',
|
|
65
|
+
content: 'Remote content',
|
|
66
|
+
content_hash: 'abc123',
|
|
67
|
+
size: 14,
|
|
68
|
+
updated_at: '2026-03-27T13:45:32Z'
|
|
69
|
+
};
|
|
70
|
+
vi.mocked(fs.existsSync).mockReturnValue(false);
|
|
71
|
+
vi.mocked(mockSubscriber.detectConflict).mockResolvedValue(null);
|
|
72
|
+
await handler.handleRemoteChange(event);
|
|
73
|
+
expect(fs.writeFileSync).toHaveBeenCalledWith('/home/user/projects/my-project/.planning/STATE.md', 'Remote content', 'utf8');
|
|
74
|
+
});
|
|
75
|
+
it('should detect conflict when local file differs', async () => {
|
|
76
|
+
const event = {
|
|
77
|
+
id: 'file-1',
|
|
78
|
+
workspace_id: 'ws-123',
|
|
79
|
+
file_path: '.planning/STATE.md',
|
|
80
|
+
content: 'Remote content',
|
|
81
|
+
content_hash: 'abc123',
|
|
82
|
+
size: 14,
|
|
83
|
+
updated_at: '2026-03-27T13:45:32Z'
|
|
84
|
+
};
|
|
85
|
+
vi.mocked(fs.existsSync).mockReturnValue(true);
|
|
86
|
+
vi.mocked(fs.readFileSync).mockReturnValue('Local content');
|
|
87
|
+
vi.mocked(mockSubscriber.detectConflict).mockResolvedValue({
|
|
88
|
+
workspace_id: 'ws-123',
|
|
89
|
+
file_path: '.planning/STATE.md',
|
|
90
|
+
local_hash: 'def456',
|
|
91
|
+
remote_hash: 'abc123',
|
|
92
|
+
local_content: 'Local content',
|
|
93
|
+
remote_content: 'Remote content',
|
|
94
|
+
detected_at: '2026-03-27T13:45:32Z'
|
|
95
|
+
});
|
|
96
|
+
await handler.handleRemoteChange(event);
|
|
97
|
+
expect(mockSubscriber.detectConflict).toHaveBeenCalledWith('ws-123', '.planning/STATE.md', 'abc123', 'Remote content', 'Local content');
|
|
98
|
+
});
|
|
99
|
+
it('should create conflict markers when conflict detected', async () => {
|
|
100
|
+
const event = {
|
|
101
|
+
id: 'file-1',
|
|
102
|
+
workspace_id: 'ws-123',
|
|
103
|
+
file_path: '.planning/STATE.md',
|
|
104
|
+
content: 'Remote content',
|
|
105
|
+
content_hash: 'abc123',
|
|
106
|
+
size: 14,
|
|
107
|
+
updated_at: '2026-03-27T13:45:32Z'
|
|
108
|
+
};
|
|
109
|
+
const conflict = {
|
|
110
|
+
workspace_id: 'ws-123',
|
|
111
|
+
file_path: '.planning/STATE.md',
|
|
112
|
+
local_hash: 'def456',
|
|
113
|
+
remote_hash: 'abc123',
|
|
114
|
+
local_content: 'Local content',
|
|
115
|
+
remote_content: 'Remote content',
|
|
116
|
+
detected_at: '2026-03-27T13:45:32Z'
|
|
117
|
+
};
|
|
118
|
+
vi.mocked(fs.existsSync).mockReturnValue(true);
|
|
119
|
+
vi.mocked(fs.readFileSync).mockReturnValue('Local content');
|
|
120
|
+
vi.mocked(mockSubscriber.detectConflict).mockResolvedValue(conflict);
|
|
121
|
+
vi.mocked(mockResolver.resolveConflict).mockReturnValue('<<<<<<< LOCAL\nLocal content\n=======\nRemote content\n>>>>>>> REMOTE');
|
|
122
|
+
await handler.handleRemoteChange(event);
|
|
123
|
+
expect(mockResolver.resolveConflict).toHaveBeenCalledWith(conflict);
|
|
124
|
+
expect(fs.writeFileSync).toHaveBeenCalledWith('/home/user/projects/my-project/.planning/STATE.md', expect.stringContaining('<<<<<<< LOCAL'), 'utf8');
|
|
125
|
+
});
|
|
126
|
+
it('should commit conflicted file to git', async () => {
|
|
127
|
+
const event = {
|
|
128
|
+
id: 'file-1',
|
|
129
|
+
workspace_id: 'ws-123',
|
|
130
|
+
file_path: '.planning/STATE.md',
|
|
131
|
+
content: 'Remote content',
|
|
132
|
+
content_hash: 'abc123',
|
|
133
|
+
size: 14,
|
|
134
|
+
updated_at: '2026-03-27T13:45:32Z'
|
|
135
|
+
};
|
|
136
|
+
const conflict = {
|
|
137
|
+
workspace_id: 'ws-123',
|
|
138
|
+
file_path: '.planning/STATE.md',
|
|
139
|
+
local_hash: 'def456',
|
|
140
|
+
remote_hash: 'abc123',
|
|
141
|
+
local_content: 'Local content',
|
|
142
|
+
remote_content: 'Remote content',
|
|
143
|
+
detected_at: '2026-03-27T13:45:32Z'
|
|
144
|
+
};
|
|
145
|
+
vi.mocked(fs.existsSync).mockReturnValue(true);
|
|
146
|
+
vi.mocked(fs.readFileSync).mockReturnValue('Local content');
|
|
147
|
+
vi.mocked(mockSubscriber.detectConflict).mockResolvedValue(conflict);
|
|
148
|
+
vi.mocked(mockResolver.resolveConflict).mockReturnValue('conflict markers');
|
|
149
|
+
await handler.handleRemoteChange(event);
|
|
150
|
+
expect(mockResolver.commitConflict).toHaveBeenCalledWith('/home/user/projects/my-project', '.planning/STATE.md');
|
|
151
|
+
});
|
|
152
|
+
it('should skip write during local sync to prevent loops', async () => {
|
|
153
|
+
const event = {
|
|
154
|
+
id: 'file-1',
|
|
155
|
+
workspace_id: 'ws-123',
|
|
156
|
+
file_path: '.planning/STATE.md',
|
|
157
|
+
content: 'Remote content',
|
|
158
|
+
content_hash: 'abc123',
|
|
159
|
+
size: 14,
|
|
160
|
+
updated_at: '2026-03-27T13:45:32Z'
|
|
161
|
+
};
|
|
162
|
+
vi.mocked(fs.existsSync).mockReturnValue(false);
|
|
163
|
+
// Create a promise that we control
|
|
164
|
+
let resolveWrite;
|
|
165
|
+
const writeDelay = new Promise(resolve => { resolveWrite = resolve; });
|
|
166
|
+
vi.mocked(fs.writeFileSync).mockImplementationOnce(() => {
|
|
167
|
+
// Don't actually block, just track the call
|
|
168
|
+
});
|
|
169
|
+
// Manually add to syncing set to simulate in-progress sync
|
|
170
|
+
const syncKey = `${event.workspace_id}:${event.file_path}`;
|
|
171
|
+
handler.syncingFiles.add(syncKey);
|
|
172
|
+
// Try to sync while already in progress
|
|
173
|
+
await handler.handleRemoteChange(event);
|
|
174
|
+
// Should log that sync was skipped
|
|
175
|
+
expect(mockLogger.debug).toHaveBeenCalledWith(expect.stringContaining('Skipping remote change - sync already in progress'), expect.any(Object));
|
|
176
|
+
// Should not write file
|
|
177
|
+
expect(fs.writeFileSync).not.toHaveBeenCalled();
|
|
178
|
+
handler.syncingFiles.delete(syncKey);
|
|
179
|
+
});
|
|
180
|
+
it('should subscribe to RealtimeSubscriber remote-change events on start', () => {
|
|
181
|
+
handler.start();
|
|
182
|
+
expect(mockSubscriber.listenerCount('remote-change')).toBeGreaterThan(0);
|
|
183
|
+
expect(mockLogger.info).toHaveBeenCalledWith(expect.stringContaining('Remote sync handler started'), expect.any(Object));
|
|
184
|
+
});
|
|
185
|
+
it('should unsubscribe from all events on stop', () => {
|
|
186
|
+
handler.start();
|
|
187
|
+
handler.stop();
|
|
188
|
+
expect(mockSubscriber.listenerCount('remote-change')).toBe(0);
|
|
189
|
+
expect(mockLogger.info).toHaveBeenCalledWith(expect.stringContaining('Remote sync handler stopped'), expect.any(Object));
|
|
190
|
+
});
|
|
191
|
+
it('should download large files from storage_url', async () => {
|
|
192
|
+
const event = {
|
|
193
|
+
id: 'file-1',
|
|
194
|
+
workspace_id: 'ws-123',
|
|
195
|
+
file_path: '.planning/large-file.md',
|
|
196
|
+
content: null,
|
|
197
|
+
content_hash: 'abc123',
|
|
198
|
+
size: 300000,
|
|
199
|
+
updated_at: '2026-03-27T13:45:32Z',
|
|
200
|
+
storage_url: 'https://storage.supabase.co/bucket/file.md'
|
|
201
|
+
};
|
|
202
|
+
vi.mocked(fs.existsSync).mockReturnValue(false);
|
|
203
|
+
vi.mocked(global.fetch).mockResolvedValue({
|
|
204
|
+
ok: true,
|
|
205
|
+
text: async () => 'Large file content'
|
|
206
|
+
});
|
|
207
|
+
await handler.handleRemoteChange(event);
|
|
208
|
+
expect(global.fetch).toHaveBeenCalledWith('https://storage.supabase.co/bucket/file.md');
|
|
209
|
+
expect(fs.writeFileSync).toHaveBeenCalledWith('/home/user/projects/my-project/.planning/large-file.md', 'Large file content', 'utf8');
|
|
210
|
+
});
|
|
211
|
+
});
|
|
212
|
+
//# sourceMappingURL=remote-sync-handler.test.js.map
|