@aprovan/patchwork 0.1.0 → 0.1.1
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/.github/workflows/publish.yml +1 -1
- package/.vscode/launch.json +19 -0
- package/README.md +24 -0
- package/apps/chat/package.json +4 -4
- package/apps/chat/vite.config.ts +8 -8
- package/docs/specs/directory-sync.md +822 -0
- package/docs/specs/patchwork-vscode.md +625 -0
- package/package.json +2 -2
- package/packages/compiler/package.json +3 -2
- package/packages/compiler/src/index.ts +13 -14
- package/packages/compiler/src/vfs/backends/http.ts +139 -0
- package/packages/compiler/src/vfs/backends/indexeddb.ts +185 -24
- package/packages/compiler/src/vfs/backends/memory.ts +166 -0
- package/packages/compiler/src/vfs/core/index.ts +26 -0
- package/packages/compiler/src/vfs/core/types.ts +93 -0
- package/packages/compiler/src/vfs/core/utils.ts +42 -0
- package/packages/compiler/src/vfs/core/virtual-fs.ts +120 -0
- package/packages/compiler/src/vfs/index.ts +37 -5
- package/packages/compiler/src/vfs/project.ts +16 -16
- package/packages/compiler/src/vfs/store.ts +183 -19
- package/packages/compiler/src/vfs/sync/differ.ts +47 -0
- package/packages/compiler/src/vfs/sync/engine.ts +398 -0
- package/packages/compiler/src/vfs/sync/index.ts +3 -0
- package/packages/compiler/src/vfs/sync/resolver.ts +46 -0
- package/packages/compiler/src/vfs/types.ts +1 -8
- package/packages/compiler/tsup.config.ts +5 -5
- package/packages/editor/package.json +1 -1
- package/packages/editor/src/components/CodeBlockExtension.tsx +1 -1
- package/packages/editor/src/components/CodePreview.tsx +59 -1
- package/packages/editor/src/components/edit/CodeBlockView.tsx +72 -0
- package/packages/editor/src/components/edit/EditModal.tsx +169 -28
- package/packages/editor/src/components/edit/FileTree.tsx +67 -13
- package/packages/editor/src/components/edit/MediaPreview.tsx +106 -0
- package/packages/editor/src/components/edit/SaveConfirmDialog.tsx +60 -0
- package/packages/editor/src/components/edit/fileTypes.ts +125 -0
- package/packages/editor/src/components/edit/index.ts +4 -0
- package/packages/editor/src/components/edit/types.ts +3 -0
- package/packages/editor/src/components/edit/useEditSession.ts +22 -4
- package/packages/editor/src/index.ts +17 -0
- package/packages/editor/src/lib/diff.ts +2 -1
- package/packages/editor/src/lib/vfs.ts +28 -10
- package/packages/editor/tsup.config.ts +10 -5
- package/packages/stitchery/package.json +5 -3
- package/packages/stitchery/src/server/index.ts +57 -57
- package/packages/stitchery/src/server/vfs-routes.ts +246 -56
- package/packages/stitchery/tsup.config.ts +5 -5
- package/packages/utcp/package.json +3 -2
- package/packages/utcp/tsconfig.json +6 -2
- package/packages/utcp/tsup.config.ts +6 -6
- package/packages/vscode/README.md +31 -0
- package/packages/vscode/media/outline.png +0 -0
- package/packages/vscode/media/outline.svg +70 -0
- package/packages/vscode/media/patchwork.png +0 -0
- package/packages/vscode/media/patchwork.svg +72 -0
- package/packages/vscode/node_modules/.bin/jiti +17 -0
- package/packages/vscode/node_modules/.bin/tsc +17 -0
- package/packages/vscode/node_modules/.bin/tsserver +17 -0
- package/packages/vscode/node_modules/.bin/tsup +17 -0
- package/packages/vscode/node_modules/.bin/tsup-node +17 -0
- package/packages/vscode/node_modules/.bin/tsx +17 -0
- package/packages/vscode/package.json +136 -0
- package/packages/vscode/src/extension.ts +612 -0
- package/packages/vscode/src/providers/PatchworkFileSystemProvider.ts +205 -0
- package/packages/vscode/src/providers/PatchworkTreeProvider.ts +177 -0
- package/packages/vscode/src/providers/PreviewPanelProvider.ts +536 -0
- package/packages/vscode/src/services/EditService.ts +24 -0
- package/packages/vscode/src/services/EmbeddedStitchery.ts +82 -0
- package/packages/vscode/tsconfig.json +13 -0
- package/packages/vscode/tsup.config.ts +11 -0
- package/packages/compiler/src/vfs/backends/local-fs.ts +0 -41
- package/packages/compiler/src/vfs/backends/s3.ts +0 -60
|
@@ -0,0 +1,822 @@
|
|
|
1
|
+
# Patchwork Directory Sync
|
|
2
|
+
|
|
3
|
+
Patchwork now has a full virtual filesystem setup, where we can load files/directories and edit files individually, saving them to the remote store as-needed.
|
|
4
|
+
|
|
5
|
+
Extend this functionality, allowing for a generic implementation where we sync changes with remote sources (a local directory, later an S3 bucket...)
|
|
6
|
+
|
|
7
|
+
Generally, we want to maintain compatibility with Node-like FS operations, as we need a lot of functionality to run in the browser.
|
|
8
|
+
|
|
9
|
+
---
|
|
10
|
+
|
|
11
|
+
## Executive Summary
|
|
12
|
+
|
|
13
|
+
This document outlines a comprehensive plan to refactor Patchwork's virtual filesystem into a bidirectional sync system supporting multiple backends (local directories, S3, IndexedDB) with proper change tracking, conflict resolution, and event-based updates.
|
|
14
|
+
|
|
15
|
+
**Key Goals:**
|
|
16
|
+
- Unified `FSProvider` interface matching Node.js `fs/promises` semantics
|
|
17
|
+
- Bidirectional sync between in-memory state and remote backends
|
|
18
|
+
- Event-driven change propagation (watch capabilities)
|
|
19
|
+
- Clean separation between storage backends and sync logic
|
|
20
|
+
- Breaking changes acceptable; provide clear migration path
|
|
21
|
+
|
|
22
|
+
---
|
|
23
|
+
|
|
24
|
+
## Current Architecture
|
|
25
|
+
|
|
26
|
+
### Package Structure
|
|
27
|
+
|
|
28
|
+
```
|
|
29
|
+
packages/compiler/src/vfs/
|
|
30
|
+
├── backends/
|
|
31
|
+
│ ├── indexeddb.ts # Browser storage
|
|
32
|
+
│ ├── local-fs.ts # HTTP → stitchery server
|
|
33
|
+
│ └── s3.ts # Direct S3 (no auth signing)
|
|
34
|
+
├── project.ts # VirtualProject utilities
|
|
35
|
+
├── store.ts # VFSStore class
|
|
36
|
+
└── types.ts # Core interfaces
|
|
37
|
+
```
|
|
38
|
+
|
|
39
|
+
### Existing Interfaces
|
|
40
|
+
|
|
41
|
+
```typescript
|
|
42
|
+
// Current StorageBackend - flat key/value only
|
|
43
|
+
interface StorageBackend {
|
|
44
|
+
get(path: string): Promise<string | null>;
|
|
45
|
+
put(path: string, content: string): Promise<void>;
|
|
46
|
+
delete(path: string): Promise<void>;
|
|
47
|
+
list(prefix?: string): Promise<string[]>;
|
|
48
|
+
exists(path: string): Promise<boolean>;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
// Current VirtualFile - minimal metadata
|
|
52
|
+
interface VirtualFile {
|
|
53
|
+
path: string;
|
|
54
|
+
content: string;
|
|
55
|
+
language?: string;
|
|
56
|
+
note?: string;
|
|
57
|
+
encoding?: 'utf8' | 'base64';
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
// Current VirtualProject - in-memory project state
|
|
61
|
+
interface VirtualProject {
|
|
62
|
+
id: string;
|
|
63
|
+
entry: string;
|
|
64
|
+
files: Map<string, VirtualFile>;
|
|
65
|
+
}
|
|
66
|
+
```
|
|
67
|
+
|
|
68
|
+
### Current Data Flow
|
|
69
|
+
|
|
70
|
+
```
|
|
71
|
+
┌─────────────┐ ┌──────────────┐ ┌─────────────────┐
|
|
72
|
+
│ Browser │ ───► │ VFSStore │ ───► │ StorageBackend │
|
|
73
|
+
│ (React) │ │ │ │ │
|
|
74
|
+
└─────────────┘ └──────────────┘ └─────────────────┘
|
|
75
|
+
│
|
|
76
|
+
┌─────────────────────────────┼─────────────────────────────┐
|
|
77
|
+
│ │ │
|
|
78
|
+
┌──────▼──────┐ ┌────────▼────────┐ ┌────────▼────────┐
|
|
79
|
+
│ IndexedDB │ │ LocalFS │ │ S3 │
|
|
80
|
+
│ Backend │ │ Backend │ │ Backend │
|
|
81
|
+
└─────────────┘ └────────┬────────┘ └─────────────────┘
|
|
82
|
+
│ HTTP
|
|
83
|
+
┌────────▼────────┐
|
|
84
|
+
│ Stitchery │
|
|
85
|
+
│ /vfs routes │
|
|
86
|
+
└────────┬────────┘
|
|
87
|
+
│
|
|
88
|
+
┌────────▼────────┐
|
|
89
|
+
│ Local Disk │
|
|
90
|
+
│ (Node.js fs) │
|
|
91
|
+
└─────────────────┘
|
|
92
|
+
```
|
|
93
|
+
|
|
94
|
+
### Issues with Current Implementation
|
|
95
|
+
|
|
96
|
+
| Issue | Impact |
|
|
97
|
+
|-------|--------|
|
|
98
|
+
| Flat key-value semantics | No directory operations (mkdir, rmdir, readdir) |
|
|
99
|
+
| No change events | Cannot watch for external changes |
|
|
100
|
+
| Unidirectional sync | Only pushes; doesn't pull remote updates |
|
|
101
|
+
| No conflict resolution | Overwrites without checking |
|
|
102
|
+
| S3 backend incomplete | Missing AWS Signature V4 authentication |
|
|
103
|
+
| Metadata loss | File stats (mtime, size) not tracked |
|
|
104
|
+
| No atomic operations | Partial writes possible on failure |
|
|
105
|
+
|
|
106
|
+
---
|
|
107
|
+
|
|
108
|
+
## Proposed Architecture
|
|
109
|
+
|
|
110
|
+
### New Package Structure
|
|
111
|
+
|
|
112
|
+
```
|
|
113
|
+
packages/compiler/src/vfs/
|
|
114
|
+
├── core/
|
|
115
|
+
│ ├── types.ts # FSProvider, SyncEngine interfaces
|
|
116
|
+
│ ├── fs-provider.ts # Base FSProvider implementation
|
|
117
|
+
│ └── virtual-fs.ts # In-memory FS with change tracking
|
|
118
|
+
├── sync/
|
|
119
|
+
│ ├── engine.ts # Bidirectional sync orchestration
|
|
120
|
+
│ ├── differ.ts # Change detection and diff generation
|
|
121
|
+
│ └── resolver.ts # Conflict resolution strategies
|
|
122
|
+
├── backends/
|
|
123
|
+
│ ├── memory.ts # In-memory (for tests/ephemeral)
|
|
124
|
+
│ ├── indexeddb.ts # Browser IndexedDB
|
|
125
|
+
│ ├── http.ts # Generic HTTP backend (replaces local-fs)
|
|
126
|
+
│ └── s3.ts # AWS S3 with proper signing
|
|
127
|
+
├── project.ts # VirtualProject utilities (preserved)
|
|
128
|
+
└── index.ts # Public exports
|
|
129
|
+
```
|
|
130
|
+
|
|
131
|
+
### Core Interfaces
|
|
132
|
+
|
|
133
|
+
```typescript
|
|
134
|
+
/**
|
|
135
|
+
* File statistics matching Node.js fs.Stats subset
|
|
136
|
+
*/
|
|
137
|
+
interface FileStats {
|
|
138
|
+
size: number;
|
|
139
|
+
mtime: Date;
|
|
140
|
+
isFile(): boolean;
|
|
141
|
+
isDirectory(): boolean;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/**
|
|
145
|
+
* Directory entry matching Node.js fs.Dirent
|
|
146
|
+
*/
|
|
147
|
+
interface DirEntry {
|
|
148
|
+
name: string;
|
|
149
|
+
isFile(): boolean;
|
|
150
|
+
isDirectory(): boolean;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/**
|
|
154
|
+
* FSProvider - Node.js fs/promises compatible interface
|
|
155
|
+
* All paths are relative to the provider's root.
|
|
156
|
+
*/
|
|
157
|
+
interface FSProvider {
|
|
158
|
+
// File operations
|
|
159
|
+
readFile(path: string, encoding?: 'utf8' | 'base64'): Promise<string>;
|
|
160
|
+
writeFile(path: string, content: string, encoding?: 'utf8' | 'base64'): Promise<void>;
|
|
161
|
+
unlink(path: string): Promise<void>;
|
|
162
|
+
|
|
163
|
+
// Directory operations
|
|
164
|
+
mkdir(path: string, options?: { recursive?: boolean }): Promise<void>;
|
|
165
|
+
rmdir(path: string, options?: { recursive?: boolean }): Promise<void>;
|
|
166
|
+
readdir(path: string): Promise<DirEntry[]>;
|
|
167
|
+
|
|
168
|
+
// Metadata
|
|
169
|
+
stat(path: string): Promise<FileStats>;
|
|
170
|
+
exists(path: string): Promise<boolean>;
|
|
171
|
+
|
|
172
|
+
// Watch (optional - backends may not support)
|
|
173
|
+
watch?(path: string, callback: WatchCallback): () => void;
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
type WatchEventType = 'create' | 'update' | 'delete';
|
|
177
|
+
type WatchCallback = (event: WatchEventType, path: string) => void;
|
|
178
|
+
|
|
179
|
+
/**
|
|
180
|
+
* Change record for sync operations
|
|
181
|
+
*/
|
|
182
|
+
interface ChangeRecord {
|
|
183
|
+
path: string;
|
|
184
|
+
type: 'create' | 'update' | 'delete';
|
|
185
|
+
content?: string;
|
|
186
|
+
mtime?: Date;
|
|
187
|
+
checksum?: string;
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
/**
|
|
191
|
+
* Conflict resolution strategy
|
|
192
|
+
*/
|
|
193
|
+
type ConflictStrategy =
|
|
194
|
+
| 'local-wins' // Always use local version (default)
|
|
195
|
+
| 'remote-wins' // Always use remote version
|
|
196
|
+
| 'newest-wins' // Compare mtime
|
|
197
|
+
| 'manual'; // Queue for user resolution
|
|
198
|
+
|
|
199
|
+
/**
|
|
200
|
+
* SyncEngine - orchestrates bidirectional sync
|
|
201
|
+
*/
|
|
202
|
+
interface SyncEngine {
|
|
203
|
+
/** Current sync status */
|
|
204
|
+
readonly status: 'idle' | 'syncing' | 'error';
|
|
205
|
+
|
|
206
|
+
/** Pending changes not yet synced */
|
|
207
|
+
readonly pendingChanges: ChangeRecord[];
|
|
208
|
+
|
|
209
|
+
/** Start continuous sync (if backend supports watch) */
|
|
210
|
+
start(): Promise<void>;
|
|
211
|
+
|
|
212
|
+
/** Stop continuous sync */
|
|
213
|
+
stop(): Promise<void>;
|
|
214
|
+
|
|
215
|
+
/** Force immediate sync */
|
|
216
|
+
sync(): Promise<SyncResult>;
|
|
217
|
+
|
|
218
|
+
/** Subscribe to sync events */
|
|
219
|
+
on(event: 'change' | 'conflict' | 'error', callback: Function): () => void;
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
interface SyncResult {
|
|
223
|
+
pushed: number;
|
|
224
|
+
pulled: number;
|
|
225
|
+
conflicts: ConflictRecord[];
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
interface ConflictRecord {
|
|
229
|
+
path: string;
|
|
230
|
+
local: ChangeRecord;
|
|
231
|
+
remote: ChangeRecord;
|
|
232
|
+
resolved?: 'local' | 'remote';
|
|
233
|
+
}
|
|
234
|
+
```
|
|
235
|
+
|
|
236
|
+
### Data Flow (Proposed)
|
|
237
|
+
|
|
238
|
+
```
|
|
239
|
+
┌─────────────────────────────────────────────────────────────────────────┐
|
|
240
|
+
│ Browser │
|
|
241
|
+
├─────────────────────────────────────────────────────────────────────────┤
|
|
242
|
+
│ ┌───────────────┐ │
|
|
243
|
+
│ │ VFSStore │ (preserved API, uses VirtualFS internally) │
|
|
244
|
+
│ └───────┬───────┘ │
|
|
245
|
+
│ │ │
|
|
246
|
+
│ ┌───────▼───────┐ ┌──────────────┐ ┌────────────────┐ │
|
|
247
|
+
│ │ VirtualFS │◄────►│ SyncEngine │◄────►│ FSProvider │ │
|
|
248
|
+
│ │ (in-memory) │ │ │ │ (backend) │ │
|
|
249
|
+
│ └───────────────┘ └──────────────┘ └───────┬────────┘ │
|
|
250
|
+
│ │ │
|
|
251
|
+
└───────────────────────────────────────────────────────┼──────────────────┘
|
|
252
|
+
│
|
|
253
|
+
┌───────────────────────────────────────────────┼───────────────────────────────────┐
|
|
254
|
+
│ │ │
|
|
255
|
+
┌───────▼───────┐ ┌────────▼────────┐ ┌──────────▼──────────┐
|
|
256
|
+
│ IndexedDB │ │ HTTP Backend │ │ S3 Backend │
|
|
257
|
+
│ Backend │ │ │ │ (signed requests) │
|
|
258
|
+
└───────────────┘ └────────┬────────┘ └─────────────────────┘
|
|
259
|
+
│
|
|
260
|
+
┌────────▼────────┐
|
|
261
|
+
│ Stitchery │
|
|
262
|
+
│ /vfs routes │
|
|
263
|
+
└────────┬────────┘
|
|
264
|
+
│
|
|
265
|
+
┌────────▼────────┐
|
|
266
|
+
│ Local Disk │
|
|
267
|
+
└─────────────────┘
|
|
268
|
+
```
|
|
269
|
+
|
|
270
|
+
---
|
|
271
|
+
|
|
272
|
+
## Breaking Changes
|
|
273
|
+
|
|
274
|
+
### Removed/Renamed
|
|
275
|
+
|
|
276
|
+
| Old | New | Migration |
|
|
277
|
+
|-----|-----|-----------|
|
|
278
|
+
| `StorageBackend` | `FSProvider` | Implement new interface methods |
|
|
279
|
+
| `LocalFSBackend` | `HttpBackend` | Rename import, update config |
|
|
280
|
+
| `VFSStore.getFile()` | `VFSStore.readFile()` | Update call sites |
|
|
281
|
+
| `VFSStore.putFile()` | `VFSStore.writeFile()` | Update call sites |
|
|
282
|
+
|
|
283
|
+
### New Required Methods
|
|
284
|
+
|
|
285
|
+
Backends must implement these additional methods:
|
|
286
|
+
|
|
287
|
+
```typescript
|
|
288
|
+
// Old interface (5 methods)
|
|
289
|
+
interface StorageBackend {
|
|
290
|
+
get, put, delete, list, exists
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
// New interface (10 methods)
|
|
294
|
+
interface FSProvider {
|
|
295
|
+
readFile, writeFile, unlink, // File ops
|
|
296
|
+
mkdir, rmdir, readdir, // Directory ops
|
|
297
|
+
stat, exists, // Metadata
|
|
298
|
+
watch? // Optional
|
|
299
|
+
}
|
|
300
|
+
```
|
|
301
|
+
|
|
302
|
+
### VFSStore API Changes
|
|
303
|
+
|
|
304
|
+
```typescript
|
|
305
|
+
// BEFORE
|
|
306
|
+
const store = new VFSStore(backend);
|
|
307
|
+
const file = await store.getFile('path');
|
|
308
|
+
await store.putFile({ path: 'x', content: 'y' });
|
|
309
|
+
|
|
310
|
+
// AFTER
|
|
311
|
+
const store = new VFSStore(provider, { sync: true });
|
|
312
|
+
const content = await store.readFile('path');
|
|
313
|
+
await store.writeFile('x', 'y');
|
|
314
|
+
|
|
315
|
+
// New capabilities
|
|
316
|
+
store.on('change', (path, type) => console.log(path, type));
|
|
317
|
+
await store.sync(); // Force sync
|
|
318
|
+
```
|
|
319
|
+
|
|
320
|
+
---
|
|
321
|
+
|
|
322
|
+
## Implementation Phases
|
|
323
|
+
|
|
324
|
+
## Current Status (Feb 22, 2026)
|
|
325
|
+
|
|
326
|
+
- Phase 1 complete: `FSProvider` types, `VirtualFS`, and `MemoryBackend` are in place.
|
|
327
|
+
- Phase 2 complete: stitchery `/vfs` routes support `stat`, `mkdir`, `readdir`, `rmdir`, and `watch`; `HttpBackend` aligns with the new interface.
|
|
328
|
+
- Phase 3 complete: `SyncEngine` handles push/pull, mtime-based conflict detection, remote watch events, and checksum-based conflict resolution via `differ.ts` and `resolver.ts`.
|
|
329
|
+
- Phase 4 complete: IndexedDB backend supports directory semantics and `stat` metadata.
|
|
330
|
+
- Phase 5 not started: S3 backend/signing is removed and needs a full re-implementation.
|
|
331
|
+
- Phase 6 complete: chat editor VFS sync + change listeners wired; zolvery types deduplicated to use compiler exports.
|
|
332
|
+
- Tests: new unit/integration coverage for FSProvider and sync engine is still pending.
|
|
333
|
+
|
|
334
|
+
### Phase 1: Core Interfaces & In-Memory Backend (1-2 days)
|
|
335
|
+
|
|
336
|
+
**Goal:** Establish new type system, implement VirtualFS with change tracking.
|
|
337
|
+
|
|
338
|
+
1. Create `FSProvider` interface in `core/types.ts`
|
|
339
|
+
2. Implement `VirtualFS` class (in-memory FSProvider with events)
|
|
340
|
+
3. Add `ChangeTracker` for recording modifications
|
|
341
|
+
4. Write tests for basic FS operations
|
|
342
|
+
|
|
343
|
+
**Deliverables:**
|
|
344
|
+
- `packages/compiler/src/vfs/core/types.ts`
|
|
345
|
+
- `packages/compiler/src/vfs/core/virtual-fs.ts`
|
|
346
|
+
- `packages/compiler/src/vfs/backends/memory.ts`
|
|
347
|
+
|
|
348
|
+
### Phase 2: HTTP Backend Upgrade (1 day)
|
|
349
|
+
|
|
350
|
+
**Goal:** Upgrade stitchery VFS routes and HTTP backend to support full FSProvider interface.
|
|
351
|
+
|
|
352
|
+
1. Add directory operations to stitchery `/vfs` routes:
|
|
353
|
+
- `POST /vfs?mkdir=path` - Create directory
|
|
354
|
+
- `DELETE /vfs/path?recursive=true` - Remove directory
|
|
355
|
+
- `GET /vfs/path?stat=true` - Get file stats
|
|
356
|
+
2. Update `HttpBackend` to implement `FSProvider`
|
|
357
|
+
3. Add SSE endpoint for watch: `GET /vfs?watch=path`
|
|
358
|
+
|
|
359
|
+
**Deliverables:**
|
|
360
|
+
- Updated `packages/stitchery/src/server/vfs-routes.ts`
|
|
361
|
+
- New `packages/compiler/src/vfs/backends/http.ts`
|
|
362
|
+
|
|
363
|
+
### Phase 3: SyncEngine Implementation (2-3 days)
|
|
364
|
+
|
|
365
|
+
**Goal:** Build bidirectional sync with conflict handling.
|
|
366
|
+
|
|
367
|
+
1. Implement `SyncEngine` class with:
|
|
368
|
+
- Change diffing (checksum-based)
|
|
369
|
+
- Push/pull orchestration
|
|
370
|
+
- Conflict detection
|
|
371
|
+
2. Add conflict resolution strategies (`local-wins` default)
|
|
372
|
+
3. Integrate with `VFSStore`
|
|
373
|
+
|
|
374
|
+
**Deliverables:**
|
|
375
|
+
- `packages/compiler/src/vfs/sync/engine.ts`
|
|
376
|
+
- `packages/compiler/src/vfs/sync/differ.ts`
|
|
377
|
+
- `packages/compiler/src/vfs/sync/resolver.ts`
|
|
378
|
+
|
|
379
|
+
### Phase 4: IndexedDB Backend Upgrade (1 day)
|
|
380
|
+
|
|
381
|
+
**Goal:** Add directory semantics to browser storage.
|
|
382
|
+
|
|
383
|
+
1. Implement virtual directory structure over flat IDB
|
|
384
|
+
2. Add `stat` metadata storage
|
|
385
|
+
3. Maintain backwards compatibility with existing stored data
|
|
386
|
+
|
|
387
|
+
**Deliverables:**
|
|
388
|
+
- Updated `packages/compiler/src/vfs/backends/indexeddb.ts`
|
|
389
|
+
|
|
390
|
+
### Phase 5: S3 Backend with Auth (1-2 days)
|
|
391
|
+
|
|
392
|
+
**Goal:** Production-ready S3 storage with AWS Signature V4.
|
|
393
|
+
|
|
394
|
+
1. Implement AWS Signature V4 signing
|
|
395
|
+
2. Add presigned URL support for browser-direct uploads
|
|
396
|
+
3. Implement `watch` via S3 Events → WebSocket (optional)
|
|
397
|
+
|
|
398
|
+
**Deliverables:**
|
|
399
|
+
- Updated `packages/compiler/src/vfs/backends/s3.ts`
|
|
400
|
+
|
|
401
|
+
### Phase 6: Client Migration (1-2 days)
|
|
402
|
+
|
|
403
|
+
**Goal:** Update Patchwork apps (chat, zolvery) and document migration for other clients.
|
|
404
|
+
|
|
405
|
+
1. Update `@aprovan/patchwork-editor` VFS utilities
|
|
406
|
+
2. Update chat app to use new APIs
|
|
407
|
+
3. Update zolvery app to use new APIs
|
|
408
|
+
4. Write migration guide
|
|
409
|
+
|
|
410
|
+
---
|
|
411
|
+
|
|
412
|
+
## Chat App Migration Guide
|
|
413
|
+
|
|
414
|
+
### Step 1: Update VFS Imports
|
|
415
|
+
|
|
416
|
+
```typescript
|
|
417
|
+
// BEFORE (packages/editor/src/lib/vfs.ts)
|
|
418
|
+
import {
|
|
419
|
+
VFSStore,
|
|
420
|
+
LocalFSBackend,
|
|
421
|
+
type VirtualProject,
|
|
422
|
+
type VirtualFile
|
|
423
|
+
} from '@aprovan/patchwork-compiler';
|
|
424
|
+
|
|
425
|
+
// AFTER
|
|
426
|
+
import {
|
|
427
|
+
VFSStore,
|
|
428
|
+
HttpBackend,
|
|
429
|
+
type VirtualProject,
|
|
430
|
+
type VirtualFile
|
|
431
|
+
} from '@aprovan/patchwork-compiler';
|
|
432
|
+
```
|
|
433
|
+
|
|
434
|
+
### Step 2: Update Store Initialization
|
|
435
|
+
|
|
436
|
+
```typescript
|
|
437
|
+
// BEFORE
|
|
438
|
+
export function getVFSStore(): VFSStore {
|
|
439
|
+
if (!storeInstance) {
|
|
440
|
+
const backend = new LocalFSBackend({ baseUrl: VFS_BASE_URL });
|
|
441
|
+
storeInstance = new VFSStore(backend);
|
|
442
|
+
}
|
|
443
|
+
return storeInstance;
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
// AFTER
|
|
447
|
+
export function getVFSStore(): VFSStore {
|
|
448
|
+
if (!storeInstance) {
|
|
449
|
+
const provider = new HttpBackend({ baseUrl: VFS_BASE_URL });
|
|
450
|
+
storeInstance = new VFSStore(provider, {
|
|
451
|
+
sync: true, // Enable auto-sync
|
|
452
|
+
conflictStrategy: 'local-wins'
|
|
453
|
+
});
|
|
454
|
+
}
|
|
455
|
+
return storeInstance;
|
|
456
|
+
}
|
|
457
|
+
```
|
|
458
|
+
|
|
459
|
+
### Step 3: Update File Operations
|
|
460
|
+
|
|
461
|
+
```typescript
|
|
462
|
+
// BEFORE
|
|
463
|
+
export async function saveFile(file: VirtualFile): Promise<void> {
|
|
464
|
+
const store = getVFSStore();
|
|
465
|
+
await store.putFile(file);
|
|
466
|
+
}
|
|
467
|
+
|
|
468
|
+
// AFTER
|
|
469
|
+
export async function saveFile(path: string, content: string): Promise<void> {
|
|
470
|
+
const store = getVFSStore();
|
|
471
|
+
await store.writeFile(path, content);
|
|
472
|
+
}
|
|
473
|
+
```
|
|
474
|
+
|
|
475
|
+
### Step 4: Add Change Listeners (Optional)
|
|
476
|
+
|
|
477
|
+
```typescript
|
|
478
|
+
// New capability - react to external changes
|
|
479
|
+
export function subscribeToChanges(
|
|
480
|
+
callback: (path: string, type: 'create' | 'update' | 'delete') => void
|
|
481
|
+
): () => void {
|
|
482
|
+
const store = getVFSStore();
|
|
483
|
+
return store.on('change', callback);
|
|
484
|
+
}
|
|
485
|
+
```
|
|
486
|
+
|
|
487
|
+
### Step 5: Update CodePreview Save Logic
|
|
488
|
+
|
|
489
|
+
```typescript
|
|
490
|
+
// In CodePreview.tsx
|
|
491
|
+
// BEFORE
|
|
492
|
+
const handleSave = useCallback(async () => {
|
|
493
|
+
setSaveStatus('saving');
|
|
494
|
+
try {
|
|
495
|
+
const project = createSingleFileProject(currentCode, entryFile, projectId);
|
|
496
|
+
await saveProject(project);
|
|
497
|
+
setSaveStatus('saved');
|
|
498
|
+
} catch { ... }
|
|
499
|
+
}, [...]);
|
|
500
|
+
|
|
501
|
+
// AFTER
|
|
502
|
+
const handleSave = useCallback(async () => {
|
|
503
|
+
setSaveStatus('saving');
|
|
504
|
+
try {
|
|
505
|
+
const path = `${projectId}/${entryFile}`;
|
|
506
|
+
await saveFile(path, currentCode);
|
|
507
|
+
setSaveStatus('saved');
|
|
508
|
+
} catch { ... }
|
|
509
|
+
}, [...]);
|
|
510
|
+
```
|
|
511
|
+
|
|
512
|
+
---
|
|
513
|
+
|
|
514
|
+
## Zolvery App Migration Guide
|
|
515
|
+
|
|
516
|
+
### Step 1: Update VFS Imports
|
|
517
|
+
|
|
518
|
+
```typescript
|
|
519
|
+
// BEFORE
|
|
520
|
+
import {
|
|
521
|
+
VFSStore,
|
|
522
|
+
LocalFSBackend,
|
|
523
|
+
type VirtualProject
|
|
524
|
+
} from '@aprovan/patchwork-compiler';
|
|
525
|
+
|
|
526
|
+
// AFTER
|
|
527
|
+
import {
|
|
528
|
+
VFSStore,
|
|
529
|
+
HttpBackend,
|
|
530
|
+
type VirtualProject
|
|
531
|
+
} from '@aprovan/patchwork-compiler';
|
|
532
|
+
```
|
|
533
|
+
|
|
534
|
+
### Step 2: Initialize Store with Sync
|
|
535
|
+
|
|
536
|
+
```typescript
|
|
537
|
+
// BEFORE
|
|
538
|
+
const backend = new LocalFSBackend({ baseUrl: VFS_BASE_URL });
|
|
539
|
+
const store = new VFSStore(backend);
|
|
540
|
+
|
|
541
|
+
// AFTER
|
|
542
|
+
const provider = new HttpBackend({ baseUrl: VFS_BASE_URL });
|
|
543
|
+
const store = new VFSStore(provider, {
|
|
544
|
+
sync: true,
|
|
545
|
+
conflictStrategy: 'local-wins'
|
|
546
|
+
});
|
|
547
|
+
```
|
|
548
|
+
|
|
549
|
+
### Step 3: Replace File APIs
|
|
550
|
+
|
|
551
|
+
```typescript
|
|
552
|
+
// BEFORE
|
|
553
|
+
await store.putFile({ path, content });
|
|
554
|
+
const file = await store.getFile(path);
|
|
555
|
+
|
|
556
|
+
// AFTER
|
|
557
|
+
await store.writeFile(path, content);
|
|
558
|
+
const content = await store.readFile(path);
|
|
559
|
+
```
|
|
560
|
+
|
|
561
|
+
### Step 4: Update Project Save/Load Helpers
|
|
562
|
+
|
|
563
|
+
```typescript
|
|
564
|
+
// BEFORE
|
|
565
|
+
export async function saveProject(project: VirtualProject) {
|
|
566
|
+
await store.putProject(project);
|
|
567
|
+
}
|
|
568
|
+
|
|
569
|
+
// AFTER
|
|
570
|
+
export async function saveProject(project: VirtualProject) {
|
|
571
|
+
await store.writeProject(project);
|
|
572
|
+
}
|
|
573
|
+
```
|
|
574
|
+
|
|
575
|
+
---
|
|
576
|
+
|
|
577
|
+
## Stitchery Server Upgrade
|
|
578
|
+
|
|
579
|
+
### New VFS Routes
|
|
580
|
+
|
|
581
|
+
```typescript
|
|
582
|
+
// packages/stitchery/src/server/vfs-routes.ts
|
|
583
|
+
|
|
584
|
+
// Existing routes (unchanged)
|
|
585
|
+
GET /vfs → List all files
|
|
586
|
+
GET /vfs/:path → Read file content
|
|
587
|
+
PUT /vfs/:path → Write file content
|
|
588
|
+
DELETE /vfs/:path → Delete file
|
|
589
|
+
|
|
590
|
+
// New routes
|
|
591
|
+
GET /vfs/:path?stat=true → Get file/dir stats
|
|
592
|
+
POST /vfs/:path?mkdir=true → Create directory
|
|
593
|
+
DELETE /vfs/:path?recursive=true → Delete directory recursively
|
|
594
|
+
GET /vfs?watch=:path → SSE stream for file changes
|
|
595
|
+
|
|
596
|
+
// Stats response
|
|
597
|
+
{
|
|
598
|
+
"size": 1234,
|
|
599
|
+
"mtime": "2024-01-15T12:00:00Z",
|
|
600
|
+
"isFile": true,
|
|
601
|
+
"isDirectory": false
|
|
602
|
+
}
|
|
603
|
+
|
|
604
|
+
// Watch event format (SSE)
|
|
605
|
+
event: change
|
|
606
|
+
data: {"type":"update","path":"project/file.tsx","mtime":"..."}
|
|
607
|
+
```
|
|
608
|
+
|
|
609
|
+
### Route Behavior Details
|
|
610
|
+
|
|
611
|
+
- Paths are always relative to the VFS root. Normalize `.` and `..` and reject escaping the root.
|
|
612
|
+
- `readdir` returns only direct children, sorted lexicographically, excluding `.` and `..`.
|
|
613
|
+
- `stat` returns `isFile` and `isDirectory` based on the underlying FS entry, not inferred from content.
|
|
614
|
+
- `mkdir` supports `recursive=true` and is idempotent for existing directories.
|
|
615
|
+
- `rmdir` with `recursive=true` removes all descendants; without it, fails on non-empty directories.
|
|
616
|
+
- `unlink` returns 404 for missing paths and 409 when attempting to delete a directory without `recursive=true`.
|
|
617
|
+
- `watch` streams `create`, `update`, and `delete` events, with a root-relative `path` and RFC3339 `mtime`.
|
|
618
|
+
|
|
619
|
+
### Error Mapping
|
|
620
|
+
|
|
621
|
+
- 400: Invalid query params or unsupported operation
|
|
622
|
+
- 404: Path not found
|
|
623
|
+
- 409: Directory not empty or type mismatch
|
|
624
|
+
- 500: Unhandled server error
|
|
625
|
+
|
|
626
|
+
### Watch Implementation
|
|
627
|
+
|
|
628
|
+
```typescript
|
|
629
|
+
// Simple watch using fs.watch (Node.js)
|
|
630
|
+
import { watch } from 'node:fs';
|
|
631
|
+
|
|
632
|
+
const watchers = new Map<string, Set<ServerResponse>>();
|
|
633
|
+
|
|
634
|
+
export function handleVFSWatch(
|
|
635
|
+
req: IncomingMessage,
|
|
636
|
+
res: ServerResponse,
|
|
637
|
+
ctx: VFSContext
|
|
638
|
+
): void {
|
|
639
|
+
const query = new URL(req.url!, 'http://localhost').searchParams;
|
|
640
|
+
const watchPath = query.get('watch');
|
|
641
|
+
|
|
642
|
+
if (!watchPath) return;
|
|
643
|
+
|
|
644
|
+
res.setHeader('Content-Type', 'text/event-stream');
|
|
645
|
+
res.setHeader('Cache-Control', 'no-cache');
|
|
646
|
+
res.setHeader('Connection', 'keep-alive');
|
|
647
|
+
|
|
648
|
+
const fullPath = join(ctx.rootDir, watchPath);
|
|
649
|
+
|
|
650
|
+
const watcher = watch(fullPath, { recursive: true }, (event, filename) => {
|
|
651
|
+
const data = JSON.stringify({
|
|
652
|
+
type: event === 'rename' ? 'create' : 'update',
|
|
653
|
+
path: filename,
|
|
654
|
+
mtime: new Date().toISOString()
|
|
655
|
+
});
|
|
656
|
+
res.write(`event: change\ndata: ${data}\n\n`);
|
|
657
|
+
});
|
|
658
|
+
|
|
659
|
+
req.on('close', () => watcher.close());
|
|
660
|
+
}
|
|
661
|
+
```
|
|
662
|
+
|
|
663
|
+
---
|
|
664
|
+
|
|
665
|
+
## Testing Strategy
|
|
666
|
+
|
|
667
|
+
### Unit Tests
|
|
668
|
+
|
|
669
|
+
```typescript
|
|
670
|
+
// Test FSProvider implementations
|
|
671
|
+
describe('FSProvider', () => {
|
|
672
|
+
const providers = [
|
|
673
|
+
['Memory', () => new MemoryBackend()],
|
|
674
|
+
['IndexedDB', () => new IndexedDBBackend()],
|
|
675
|
+
['HTTP', () => new HttpBackend({ baseUrl: 'http://test' })]
|
|
676
|
+
];
|
|
677
|
+
|
|
678
|
+
test.each(providers)('%s: readFile/writeFile', async (_, create) => {
|
|
679
|
+
const fs = create();
|
|
680
|
+
await fs.writeFile('test.txt', 'hello');
|
|
681
|
+
expect(await fs.readFile('test.txt')).toBe('hello');
|
|
682
|
+
});
|
|
683
|
+
|
|
684
|
+
test.each(providers)('%s: mkdir/readdir', async (_, create) => {
|
|
685
|
+
const fs = create();
|
|
686
|
+
await fs.mkdir('dir', { recursive: true });
|
|
687
|
+
await fs.writeFile('dir/file.txt', 'content');
|
|
688
|
+
const entries = await fs.readdir('dir');
|
|
689
|
+
expect(entries).toHaveLength(1);
|
|
690
|
+
expect(entries[0].name).toBe('file.txt');
|
|
691
|
+
});
|
|
692
|
+
});
|
|
693
|
+
```
|
|
694
|
+
|
|
695
|
+
### Integration Tests
|
|
696
|
+
|
|
697
|
+
```typescript
|
|
698
|
+
// Test sync engine with mock backends
|
|
699
|
+
describe('SyncEngine', () => {
|
|
700
|
+
test('pushes local changes to remote', async () => {
|
|
701
|
+
const local = new MemoryBackend();
|
|
702
|
+
const remote = new MemoryBackend();
|
|
703
|
+
const sync = new SyncEngine(local, remote);
|
|
704
|
+
|
|
705
|
+
await local.writeFile('new.txt', 'content');
|
|
706
|
+
const result = await sync.sync();
|
|
707
|
+
|
|
708
|
+
expect(result.pushed).toBe(1);
|
|
709
|
+
expect(await remote.readFile('new.txt')).toBe('content');
|
|
710
|
+
});
|
|
711
|
+
|
|
712
|
+
test('pulls remote changes to local', async () => { ... });
|
|
713
|
+
test('detects conflicts', async () => { ... });
|
|
714
|
+
});
|
|
715
|
+
```
|
|
716
|
+
|
|
717
|
+
---
|
|
718
|
+
|
|
719
|
+
## Open Questions
|
|
720
|
+
|
|
721
|
+
1. **Conflict UI**: How should conflicts be displayed?
|
|
722
|
+
- *Recommendation:* Default to `local-wins` for simplicity. Patchwork is local-first.
|
|
723
|
+
|
|
724
|
+
2. **Watch granularity**: Watch entire VFS root, or per-project?
|
|
725
|
+
- *Recommendation:* Per-project for performance; add `store.watch(projectId)`
|
|
726
|
+
|
|
727
|
+
3. **Binary files**: Current system is text-only. Support binary?
|
|
728
|
+
- *Recommendation:* Treat binary files as remote references. Store locally for viewing, override on save (no conflict resolution for binary)
|
|
729
|
+
|
|
730
|
+
4. **Offline support**: Queue changes when offline?
|
|
731
|
+
- *Recommendation:* Defer for now. Keep abstractions clean for future implementation, but no built-in support yet.
|
|
732
|
+
|
|
733
|
+
---
|
|
734
|
+
|
|
735
|
+
## Timeline Estimate
|
|
736
|
+
|
|
737
|
+
| Phase | Duration | Dependencies |
|
|
738
|
+
|-------|----------|--------------|
|
|
739
|
+
| 1. Core Interfaces | 1-2 days | None |
|
|
740
|
+
| 2. HTTP Backend | 1 day | Phase 1 |
|
|
741
|
+
| 3. SyncEngine | 2-3 days | Phase 1, 2 |
|
|
742
|
+
| 4. IndexedDB Upgrade | 1 day | Phase 1 |
|
|
743
|
+
| 5. S3 Backend | 1-2 days | Phase 1 |
|
|
744
|
+
| 6. Client Migration | 1-2 days | Phase 2, 3 |
|
|
745
|
+
|
|
746
|
+
**Total: 7-11 days**
|
|
747
|
+
|
|
748
|
+
---
|
|
749
|
+
|
|
750
|
+
## Appendix: Full Interface Definitions
|
|
751
|
+
|
|
752
|
+
```typescript
|
|
753
|
+
// packages/compiler/src/vfs/core/types.ts
|
|
754
|
+
|
|
755
|
+
export interface FileStats {
|
|
756
|
+
size: number;
|
|
757
|
+
mtime: Date;
|
|
758
|
+
isFile(): boolean;
|
|
759
|
+
isDirectory(): boolean;
|
|
760
|
+
}
|
|
761
|
+
|
|
762
|
+
export interface DirEntry {
|
|
763
|
+
name: string;
|
|
764
|
+
isFile(): boolean;
|
|
765
|
+
isDirectory(): boolean;
|
|
766
|
+
}
|
|
767
|
+
|
|
768
|
+
export type WatchEventType = 'create' | 'update' | 'delete';
|
|
769
|
+
export type WatchCallback = (event: WatchEventType, path: string) => void;
|
|
770
|
+
|
|
771
|
+
export interface FSProvider {
|
|
772
|
+
readFile(path: string, encoding?: 'utf8' | 'base64'): Promise<string>;
|
|
773
|
+
writeFile(path: string, content: string, encoding?: 'utf8' | 'base64'): Promise<void>;
|
|
774
|
+
unlink(path: string): Promise<void>;
|
|
775
|
+
mkdir(path: string, options?: { recursive?: boolean }): Promise<void>;
|
|
776
|
+
rmdir(path: string, options?: { recursive?: boolean }): Promise<void>;
|
|
777
|
+
readdir(path: string): Promise<DirEntry[]>;
|
|
778
|
+
stat(path: string): Promise<FileStats>;
|
|
779
|
+
exists(path: string): Promise<boolean>;
|
|
780
|
+
watch?(path: string, callback: WatchCallback): () => void;
|
|
781
|
+
}
|
|
782
|
+
|
|
783
|
+
export interface ChangeRecord {
|
|
784
|
+
path: string;
|
|
785
|
+
type: WatchEventType;
|
|
786
|
+
content?: string;
|
|
787
|
+
mtime?: Date;
|
|
788
|
+
checksum?: string;
|
|
789
|
+
}
|
|
790
|
+
|
|
791
|
+
export type ConflictStrategy = 'local-wins' | 'remote-wins' | 'newest-wins' | 'manual';
|
|
792
|
+
|
|
793
|
+
export interface SyncOptions {
|
|
794
|
+
conflictStrategy?: ConflictStrategy;
|
|
795
|
+
autoSync?: boolean;
|
|
796
|
+
syncInterval?: number;
|
|
797
|
+
}
|
|
798
|
+
|
|
799
|
+
export interface SyncResult {
|
|
800
|
+
pushed: number;
|
|
801
|
+
pulled: number;
|
|
802
|
+
conflicts: ConflictRecord[];
|
|
803
|
+
}
|
|
804
|
+
|
|
805
|
+
export interface ConflictRecord {
|
|
806
|
+
path: string;
|
|
807
|
+
local: ChangeRecord;
|
|
808
|
+
remote: ChangeRecord;
|
|
809
|
+
resolved?: 'local' | 'remote';
|
|
810
|
+
}
|
|
811
|
+
|
|
812
|
+
export interface SyncEngine {
|
|
813
|
+
readonly status: 'idle' | 'syncing' | 'error';
|
|
814
|
+
readonly pendingChanges: ChangeRecord[];
|
|
815
|
+
start(): Promise<void>;
|
|
816
|
+
stop(): Promise<void>;
|
|
817
|
+
sync(): Promise<SyncResult>;
|
|
818
|
+
on(event: 'change', callback: (path: string, type: WatchEventType) => void): () => void;
|
|
819
|
+
on(event: 'conflict', callback: (conflict: ConflictRecord) => void): () => void;
|
|
820
|
+
on(event: 'error', callback: (error: Error) => void): () => void;
|
|
821
|
+
}
|
|
822
|
+
```
|