@aprovan/hardcopy 0.1.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/.eslintrc.json +22 -0
- package/.github/workflows/publish.yml +41 -0
- package/.prettierignore +17 -0
- package/LICENSE +21 -0
- package/README.md +183 -0
- package/dist/cli.d.ts +1 -0
- package/dist/cli.js +2950 -0
- package/dist/index.d.ts +406 -0
- package/dist/index.js +2737 -0
- package/dist/mcp-server.d.ts +7 -0
- package/dist/mcp-server.js +2665 -0
- package/docs/research/crdt.md +777 -0
- package/docs/research/github-issues.md +684 -0
- package/docs/research/gql.md +876 -0
- package/docs/research/index.md +19 -0
- package/docs/specs/conflict-resolution.md +1254 -0
- package/docs/specs/hardcopy.md +742 -0
- package/docs/specs/patchwork-integration.md +227 -0
- package/docs/specs/plugin-architecture.md +747 -0
- package/mcp.json +8 -0
- package/package.json +64 -0
- package/scripts/install-graphqlite.ts +156 -0
- package/src/cli.ts +356 -0
- package/src/config.ts +104 -0
- package/src/conflict-store.ts +136 -0
- package/src/conflict.ts +147 -0
- package/src/crdt.ts +100 -0
- package/src/db.ts +600 -0
- package/src/env.ts +34 -0
- package/src/format.ts +72 -0
- package/src/formats/github-issue.ts +55 -0
- package/src/hardcopy/core.ts +78 -0
- package/src/hardcopy/diff.ts +188 -0
- package/src/hardcopy/index.ts +67 -0
- package/src/hardcopy/init.ts +24 -0
- package/src/hardcopy/push.ts +444 -0
- package/src/hardcopy/sync.ts +37 -0
- package/src/hardcopy/types.ts +49 -0
- package/src/hardcopy/views.ts +199 -0
- package/src/hardcopy.ts +1 -0
- package/src/index.ts +13 -0
- package/src/llm-merge.ts +109 -0
- package/src/mcp-server.ts +388 -0
- package/src/merge.ts +75 -0
- package/src/provider.ts +40 -0
- package/src/providers/a2a/index.ts +166 -0
- package/src/providers/git/index.ts +212 -0
- package/src/providers/github/index.ts +236 -0
- package/src/providers/github/issues.ts +66 -0
- package/src/providers.ts +7 -0
- package/src/types.ts +101 -0
- package/tsconfig.json +21 -0
- package/tsup.config.ts +10 -0
|
@@ -0,0 +1,777 @@
|
|
|
1
|
+
# CRDT Sync Patterns with Loro
|
|
2
|
+
|
|
3
|
+
## Overview
|
|
4
|
+
|
|
5
|
+
This document covers CRDT (Conflict-free Replicated Data Types) patterns for bi-directional sync between local files and remote APIs (GitHub, Figma, Google services, etc.). We'll use the [Loro](https://github.com/loro-dev/loro) library for conflict resolution.
|
|
6
|
+
|
|
7
|
+
---
|
|
8
|
+
|
|
9
|
+
## Why CRDTs for Sync?
|
|
10
|
+
|
|
11
|
+
### The Sync Problem
|
|
12
|
+
When syncing data bidirectionally:
|
|
13
|
+
1. **Local edits** can happen offline or between syncs
|
|
14
|
+
2. **Remote edits** can happen from web UI, mobile apps, or other clients
|
|
15
|
+
3. **Concurrent edits** create conflicts that need resolution
|
|
16
|
+
|
|
17
|
+
### Traditional Approaches
|
|
18
|
+
- **Last-write-wins**: Loses data, causes frustration
|
|
19
|
+
- **Manual conflict resolution**: Requires user intervention
|
|
20
|
+
- **Locking**: Prevents concurrent editing
|
|
21
|
+
|
|
22
|
+
### CRDT Approach
|
|
23
|
+
- **Automatic merge**: Concurrent edits merge deterministically
|
|
24
|
+
- **No data loss**: All operations are preserved
|
|
25
|
+
- **Eventual consistency**: All replicas converge to same state
|
|
26
|
+
- **Offline-first**: Works without network connectivity
|
|
27
|
+
|
|
28
|
+
---
|
|
29
|
+
|
|
30
|
+
## Loro Library Fundamentals
|
|
31
|
+
|
|
32
|
+
### Installation
|
|
33
|
+
```bash
|
|
34
|
+
npm install loro-crdt
|
|
35
|
+
# or
|
|
36
|
+
pnpm add loro-crdt
|
|
37
|
+
```
|
|
38
|
+
|
|
39
|
+
### Core Concepts
|
|
40
|
+
|
|
41
|
+
#### LoroDoc
|
|
42
|
+
The main document container that holds all CRDT data structures.
|
|
43
|
+
|
|
44
|
+
```typescript
|
|
45
|
+
import { Loro, LoroText, LoroList, LoroMap } from 'loro-crdt';
|
|
46
|
+
|
|
47
|
+
// Create a new document
|
|
48
|
+
const doc = new Loro();
|
|
49
|
+
|
|
50
|
+
// Get or create containers
|
|
51
|
+
const title = doc.getText('title');
|
|
52
|
+
const body = doc.getText('body');
|
|
53
|
+
const labels = doc.getList('labels');
|
|
54
|
+
const metadata = doc.getMap('metadata');
|
|
55
|
+
```
|
|
56
|
+
|
|
57
|
+
#### Container Types
|
|
58
|
+
|
|
59
|
+
| Type | Use Case | Operations |
|
|
60
|
+
|------|----------|------------|
|
|
61
|
+
| `LoroText` | Rich text, Markdown bodies | insert, delete, mark (formatting) |
|
|
62
|
+
| `LoroList` | Arrays, ordered items | insert, delete, push, get |
|
|
63
|
+
| `LoroMap` | Key-value pairs, metadata | set, get, delete |
|
|
64
|
+
| `LoroTree` | Hierarchical structures | create, move, delete nodes |
|
|
65
|
+
| `LoroMovableList` | Reorderable lists | insert, move, delete |
|
|
66
|
+
|
|
67
|
+
---
|
|
68
|
+
|
|
69
|
+
## Data Modeling for Sync
|
|
70
|
+
|
|
71
|
+
### GitHub Issue Model
|
|
72
|
+
|
|
73
|
+
```typescript
|
|
74
|
+
import { Loro, LoroMap, LoroText, LoroList } from 'loro-crdt';
|
|
75
|
+
|
|
76
|
+
interface IssueCRDT {
|
|
77
|
+
doc: Loro;
|
|
78
|
+
title: LoroText;
|
|
79
|
+
body: LoroText;
|
|
80
|
+
labels: LoroList;
|
|
81
|
+
assignees: LoroList;
|
|
82
|
+
metadata: LoroMap;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
function createIssueCRDT(): IssueCRDT {
|
|
86
|
+
const doc = new Loro();
|
|
87
|
+
|
|
88
|
+
return {
|
|
89
|
+
doc,
|
|
90
|
+
title: doc.getText('title'),
|
|
91
|
+
body: doc.getText('body'),
|
|
92
|
+
labels: doc.getList('labels'), // string[]
|
|
93
|
+
assignees: doc.getList('assignees'), // string[]
|
|
94
|
+
metadata: doc.getMap('metadata'), // { state, milestone, etc. }
|
|
95
|
+
};
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
// Initialize from GitHub API response
|
|
99
|
+
function initFromGitHub(crdt: IssueCRDT, issue: GitHubIssue): void {
|
|
100
|
+
crdt.title.insert(0, issue.title);
|
|
101
|
+
crdt.body.insert(0, issue.body || '');
|
|
102
|
+
|
|
103
|
+
for (const label of issue.labels) {
|
|
104
|
+
crdt.labels.push(label.name);
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
for (const assignee of issue.assignees) {
|
|
108
|
+
crdt.assignees.push(assignee.login);
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
crdt.metadata.set('state', issue.state);
|
|
112
|
+
crdt.metadata.set('state_reason', issue.state_reason);
|
|
113
|
+
crdt.metadata.set('number', issue.number);
|
|
114
|
+
crdt.metadata.set('id', issue.id);
|
|
115
|
+
crdt.metadata.set('node_id', issue.node_id);
|
|
116
|
+
crdt.metadata.set('url', issue.html_url);
|
|
117
|
+
crdt.metadata.set('created_at', issue.created_at);
|
|
118
|
+
crdt.metadata.set('updated_at', issue.updated_at);
|
|
119
|
+
crdt.metadata.set('milestone', issue.milestone?.title || null);
|
|
120
|
+
|
|
121
|
+
// Commit changes
|
|
122
|
+
doc.commit();
|
|
123
|
+
}
|
|
124
|
+
```
|
|
125
|
+
|
|
126
|
+
### Project Item Model
|
|
127
|
+
|
|
128
|
+
```typescript
|
|
129
|
+
function createProjectItemCRDT() {
|
|
130
|
+
const doc = new Loro();
|
|
131
|
+
|
|
132
|
+
return {
|
|
133
|
+
doc,
|
|
134
|
+
fieldValues: doc.getMap('fieldValues'), // { Status: "Done", Priority: "High" }
|
|
135
|
+
position: doc.getMap('position'), // { viewId: order }
|
|
136
|
+
metadata: doc.getMap('metadata'),
|
|
137
|
+
};
|
|
138
|
+
}
|
|
139
|
+
```
|
|
140
|
+
|
|
141
|
+
---
|
|
142
|
+
|
|
143
|
+
## Sync Operations
|
|
144
|
+
|
|
145
|
+
### Export/Import (Serialization)
|
|
146
|
+
|
|
147
|
+
```typescript
|
|
148
|
+
// Export modes
|
|
149
|
+
const snapshot = doc.export({ mode: 'snapshot' }); // Full state (larger, self-contained)
|
|
150
|
+
const updates = doc.export({ mode: 'update' }); // Incremental changes (smaller, requires base)
|
|
151
|
+
const shallowSnapshot = doc.export({ mode: 'shallow-snapshot', frontiers: frontiers });
|
|
152
|
+
|
|
153
|
+
// Import
|
|
154
|
+
const newDoc = new Loro();
|
|
155
|
+
newDoc.import(snapshot);
|
|
156
|
+
|
|
157
|
+
// Or import updates into existing doc
|
|
158
|
+
existingDoc.import(updates);
|
|
159
|
+
```
|
|
160
|
+
|
|
161
|
+
### Version Tracking
|
|
162
|
+
|
|
163
|
+
```typescript
|
|
164
|
+
// Get current version (frontiers)
|
|
165
|
+
const version = doc.frontiers();
|
|
166
|
+
// Returns: { peer1: 123, peer2: 456 }
|
|
167
|
+
|
|
168
|
+
// Get oplog version for comparison
|
|
169
|
+
const opVersion = doc.oplogVersion();
|
|
170
|
+
|
|
171
|
+
// Check if document has all changes from another version
|
|
172
|
+
const isAheadOf = doc.cmpWithFrontiers(otherVersion);
|
|
173
|
+
// Returns: -1 (behind), 0 (equal), 1 (ahead), undefined (concurrent/diverged)
|
|
174
|
+
```
|
|
175
|
+
|
|
176
|
+
### Merging Remote Changes
|
|
177
|
+
|
|
178
|
+
```typescript
|
|
179
|
+
async function pullRemoteChanges(
|
|
180
|
+
localDoc: Loro,
|
|
181
|
+
fetchRemote: () => Promise<{ data: any; version: string }>
|
|
182
|
+
): Promise<void> {
|
|
183
|
+
const remote = await fetchRemote();
|
|
184
|
+
|
|
185
|
+
// Create temporary doc from remote data
|
|
186
|
+
const remoteDoc = new Loro();
|
|
187
|
+
initFromGitHub(remoteDoc, remote.data);
|
|
188
|
+
|
|
189
|
+
// Export remote as updates
|
|
190
|
+
const remoteUpdates = remoteDoc.export({ mode: 'update' });
|
|
191
|
+
|
|
192
|
+
// Import into local - automatic CRDT merge
|
|
193
|
+
localDoc.import(remoteUpdates);
|
|
194
|
+
|
|
195
|
+
// Commit merged state
|
|
196
|
+
localDoc.commit();
|
|
197
|
+
}
|
|
198
|
+
```
|
|
199
|
+
|
|
200
|
+
### Detecting Local Changes
|
|
201
|
+
|
|
202
|
+
```typescript
|
|
203
|
+
function hasLocalChanges(doc: Loro, lastSyncVersion: Uint8Array): boolean {
|
|
204
|
+
const currentVersion = doc.export({ mode: 'update', from: lastSyncVersion });
|
|
205
|
+
return currentVersion.length > 0;
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
function getChangesSince(doc: Loro, sinceVersion: Uint8Array): Uint8Array {
|
|
209
|
+
return doc.export({ mode: 'update', from: sinceVersion });
|
|
210
|
+
}
|
|
211
|
+
```
|
|
212
|
+
|
|
213
|
+
---
|
|
214
|
+
|
|
215
|
+
## Text Diff and Apply
|
|
216
|
+
|
|
217
|
+
### Applying String Changes to LoroText
|
|
218
|
+
|
|
219
|
+
```typescript
|
|
220
|
+
import { diffChars } from 'diff'; // npm install diff
|
|
221
|
+
|
|
222
|
+
function applyTextChanges(loroText: LoroText, oldText: string, newText: string): void {
|
|
223
|
+
const changes = diffChars(oldText, newText);
|
|
224
|
+
let position = 0;
|
|
225
|
+
|
|
226
|
+
for (const change of changes) {
|
|
227
|
+
if (change.removed) {
|
|
228
|
+
loroText.delete(position, change.value!.length);
|
|
229
|
+
} else if (change.added) {
|
|
230
|
+
loroText.insert(position, change.value!);
|
|
231
|
+
position += change.value!.length;
|
|
232
|
+
} else {
|
|
233
|
+
position += change.value!.length;
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
// Usage: When user edits local markdown file
|
|
239
|
+
function onFileChanged(crdt: IssueCRDT, oldContent: string, newContent: string): void {
|
|
240
|
+
const { title: oldTitle, body: oldBody } = parseMarkdown(oldContent);
|
|
241
|
+
const { title: newTitle, body: newBody } = parseMarkdown(newContent);
|
|
242
|
+
|
|
243
|
+
if (oldTitle !== newTitle) {
|
|
244
|
+
applyTextChanges(crdt.title, oldTitle, newTitle);
|
|
245
|
+
}
|
|
246
|
+
if (oldBody !== newBody) {
|
|
247
|
+
applyTextChanges(crdt.body, oldBody, newBody);
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
crdt.doc.commit();
|
|
251
|
+
}
|
|
252
|
+
```
|
|
253
|
+
|
|
254
|
+
### Applying List Changes
|
|
255
|
+
|
|
256
|
+
```typescript
|
|
257
|
+
function applyListChanges<T>(
|
|
258
|
+
loroList: LoroList,
|
|
259
|
+
oldItems: T[],
|
|
260
|
+
newItems: T[],
|
|
261
|
+
getId: (item: T) => string
|
|
262
|
+
): void {
|
|
263
|
+
const oldSet = new Set(oldItems.map(getId));
|
|
264
|
+
const newSet = new Set(newItems.map(getId));
|
|
265
|
+
|
|
266
|
+
// Find removed items
|
|
267
|
+
for (let i = loroList.length - 1; i >= 0; i--) {
|
|
268
|
+
const item = loroList.get(i) as T;
|
|
269
|
+
if (!newSet.has(getId(item))) {
|
|
270
|
+
loroList.delete(i, 1);
|
|
271
|
+
}
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
// Find added items
|
|
275
|
+
for (const item of newItems) {
|
|
276
|
+
if (!oldSet.has(getId(item))) {
|
|
277
|
+
loroList.push(item);
|
|
278
|
+
}
|
|
279
|
+
}
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
// Usage for labels
|
|
283
|
+
applyListChanges(
|
|
284
|
+
crdt.labels,
|
|
285
|
+
oldIssue.labels.map(l => l.name),
|
|
286
|
+
newIssue.labels.map(l => l.name),
|
|
287
|
+
(name) => name
|
|
288
|
+
);
|
|
289
|
+
```
|
|
290
|
+
|
|
291
|
+
---
|
|
292
|
+
|
|
293
|
+
## Conflict Detection and Resolution
|
|
294
|
+
|
|
295
|
+
### Checking for Conflicts
|
|
296
|
+
|
|
297
|
+
```typescript
|
|
298
|
+
interface SyncState {
|
|
299
|
+
localVersion: Uint8Array; // Version after last sync
|
|
300
|
+
remoteVersion: string; // ETag or updated_at from API
|
|
301
|
+
lastSyncTime: number;
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
enum SyncStatus {
|
|
305
|
+
SYNCED = 'synced',
|
|
306
|
+
LOCAL_AHEAD = 'local_ahead',
|
|
307
|
+
REMOTE_AHEAD = 'remote_ahead',
|
|
308
|
+
CONFLICT = 'conflict',
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
function checkSyncStatus(
|
|
312
|
+
doc: Loro,
|
|
313
|
+
state: SyncState,
|
|
314
|
+
remoteUpdatedAt: string
|
|
315
|
+
): SyncStatus {
|
|
316
|
+
const hasLocalChanges = hasLocalChanges(doc, state.localVersion);
|
|
317
|
+
const hasRemoteChanges = remoteUpdatedAt !== state.remoteVersion;
|
|
318
|
+
|
|
319
|
+
if (!hasLocalChanges && !hasRemoteChanges) {
|
|
320
|
+
return SyncStatus.SYNCED;
|
|
321
|
+
}
|
|
322
|
+
if (hasLocalChanges && !hasRemoteChanges) {
|
|
323
|
+
return SyncStatus.LOCAL_AHEAD;
|
|
324
|
+
}
|
|
325
|
+
if (!hasLocalChanges && hasRemoteChanges) {
|
|
326
|
+
return SyncStatus.REMOTE_AHEAD;
|
|
327
|
+
}
|
|
328
|
+
return SyncStatus.CONFLICT;
|
|
329
|
+
}
|
|
330
|
+
```
|
|
331
|
+
|
|
332
|
+
### Automatic Conflict Resolution
|
|
333
|
+
|
|
334
|
+
CRDTs resolve most conflicts automatically:
|
|
335
|
+
|
|
336
|
+
```typescript
|
|
337
|
+
async function syncWithConflictResolution(
|
|
338
|
+
localDoc: Loro,
|
|
339
|
+
state: SyncState,
|
|
340
|
+
api: {
|
|
341
|
+
fetch: () => Promise<GitHubIssue>;
|
|
342
|
+
update: (data: Partial<GitHubIssue>) => Promise<void>;
|
|
343
|
+
}
|
|
344
|
+
): Promise<void> {
|
|
345
|
+
const remote = await api.fetch();
|
|
346
|
+
const status = checkSyncStatus(localDoc, state, remote.updated_at);
|
|
347
|
+
|
|
348
|
+
switch (status) {
|
|
349
|
+
case SyncStatus.SYNCED:
|
|
350
|
+
// Nothing to do
|
|
351
|
+
break;
|
|
352
|
+
|
|
353
|
+
case SyncStatus.LOCAL_AHEAD:
|
|
354
|
+
// Push local changes
|
|
355
|
+
await pushToRemote(localDoc, api);
|
|
356
|
+
break;
|
|
357
|
+
|
|
358
|
+
case SyncStatus.REMOTE_AHEAD:
|
|
359
|
+
// Pull remote changes
|
|
360
|
+
await pullFromRemote(localDoc, remote);
|
|
361
|
+
break;
|
|
362
|
+
|
|
363
|
+
case SyncStatus.CONFLICT:
|
|
364
|
+
// CRDT merge handles text/list conflicts automatically
|
|
365
|
+
await pullFromRemote(localDoc, remote);
|
|
366
|
+
await pushToRemote(localDoc, api);
|
|
367
|
+
break;
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
// Update sync state
|
|
371
|
+
state.localVersion = localDoc.export({ mode: 'update' });
|
|
372
|
+
state.remoteVersion = remote.updated_at;
|
|
373
|
+
state.lastSyncTime = Date.now();
|
|
374
|
+
}
|
|
375
|
+
```
|
|
376
|
+
|
|
377
|
+
### Single-Value Field Conflicts
|
|
378
|
+
|
|
379
|
+
For fields that can't be merged (state, milestone), use last-write-wins with logging:
|
|
380
|
+
|
|
381
|
+
```typescript
|
|
382
|
+
interface ConflictLog {
|
|
383
|
+
field: string;
|
|
384
|
+
localValue: any;
|
|
385
|
+
remoteValue: any;
|
|
386
|
+
resolvedValue: any;
|
|
387
|
+
resolvedAt: string;
|
|
388
|
+
strategy: 'local_wins' | 'remote_wins' | 'user_choice';
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
function resolveSingleValueConflict(
|
|
392
|
+
field: string,
|
|
393
|
+
localValue: any,
|
|
394
|
+
remoteValue: any,
|
|
395
|
+
localUpdatedAt: number,
|
|
396
|
+
remoteUpdatedAt: string
|
|
397
|
+
): { value: any; log: ConflictLog } {
|
|
398
|
+
// Default: most recent wins
|
|
399
|
+
const remoteTime = new Date(remoteUpdatedAt).getTime();
|
|
400
|
+
const useRemote = remoteTime > localUpdatedAt;
|
|
401
|
+
|
|
402
|
+
const resolvedValue = useRemote ? remoteValue : localValue;
|
|
403
|
+
|
|
404
|
+
return {
|
|
405
|
+
value: resolvedValue,
|
|
406
|
+
log: {
|
|
407
|
+
field,
|
|
408
|
+
localValue,
|
|
409
|
+
remoteValue,
|
|
410
|
+
resolvedValue,
|
|
411
|
+
resolvedAt: new Date().toISOString(),
|
|
412
|
+
strategy: useRemote ? 'remote_wins' : 'local_wins',
|
|
413
|
+
},
|
|
414
|
+
};
|
|
415
|
+
}
|
|
416
|
+
```
|
|
417
|
+
|
|
418
|
+
---
|
|
419
|
+
|
|
420
|
+
## Time Travel and History
|
|
421
|
+
|
|
422
|
+
### Checkout Previous Versions
|
|
423
|
+
|
|
424
|
+
```typescript
|
|
425
|
+
// Get all historical versions
|
|
426
|
+
const versions = doc.getAllChanges();
|
|
427
|
+
|
|
428
|
+
// Checkout specific version (creates detached state)
|
|
429
|
+
const historicalState = doc.checkout(specificFrontiers);
|
|
430
|
+
|
|
431
|
+
// Get JSON at that version
|
|
432
|
+
const dataAtVersion = doc.toJSON();
|
|
433
|
+
|
|
434
|
+
// Return to latest
|
|
435
|
+
doc.checkoutToLatest();
|
|
436
|
+
```
|
|
437
|
+
|
|
438
|
+
### Undo/Redo
|
|
439
|
+
|
|
440
|
+
```typescript
|
|
441
|
+
// Loro doesn't have built-in undo, but you can implement it:
|
|
442
|
+
class UndoManager {
|
|
443
|
+
private history: Uint8Array[] = [];
|
|
444
|
+
private future: Uint8Array[] = [];
|
|
445
|
+
private doc: Loro;
|
|
446
|
+
|
|
447
|
+
constructor(doc: Loro) {
|
|
448
|
+
this.doc = doc;
|
|
449
|
+
this.captureState();
|
|
450
|
+
}
|
|
451
|
+
|
|
452
|
+
captureState(): void {
|
|
453
|
+
this.history.push(this.doc.export({ mode: 'snapshot' }));
|
|
454
|
+
this.future = []; // Clear redo stack on new change
|
|
455
|
+
}
|
|
456
|
+
|
|
457
|
+
undo(): void {
|
|
458
|
+
if (this.history.length <= 1) return;
|
|
459
|
+
|
|
460
|
+
const current = this.history.pop()!;
|
|
461
|
+
this.future.push(current);
|
|
462
|
+
|
|
463
|
+
const previous = this.history[this.history.length - 1];
|
|
464
|
+
this.doc.import(previous);
|
|
465
|
+
}
|
|
466
|
+
|
|
467
|
+
redo(): void {
|
|
468
|
+
if (this.future.length === 0) return;
|
|
469
|
+
|
|
470
|
+
const next = this.future.pop()!;
|
|
471
|
+
this.history.push(next);
|
|
472
|
+
this.doc.import(next);
|
|
473
|
+
}
|
|
474
|
+
}
|
|
475
|
+
```
|
|
476
|
+
|
|
477
|
+
---
|
|
478
|
+
|
|
479
|
+
## Persistence and Storage
|
|
480
|
+
|
|
481
|
+
### File-Based Storage
|
|
482
|
+
|
|
483
|
+
```typescript
|
|
484
|
+
import { readFile, writeFile } from 'fs/promises';
|
|
485
|
+
|
|
486
|
+
const CRDT_DIR = '.hardcopy/crdt';
|
|
487
|
+
|
|
488
|
+
async function saveCRDT(issueNumber: number, doc: Loro): Promise<void> {
|
|
489
|
+
const snapshot = doc.export({ mode: 'snapshot' });
|
|
490
|
+
const path = `${CRDT_DIR}/issue-${issueNumber}.loro`;
|
|
491
|
+
await writeFile(path, Buffer.from(snapshot));
|
|
492
|
+
}
|
|
493
|
+
|
|
494
|
+
async function loadCRDT(issueNumber: number): Promise<Loro | null> {
|
|
495
|
+
const path = `${CRDT_DIR}/issue-${issueNumber}.loro`;
|
|
496
|
+
try {
|
|
497
|
+
const data = await readFile(path);
|
|
498
|
+
const doc = new Loro();
|
|
499
|
+
doc.import(new Uint8Array(data));
|
|
500
|
+
return doc;
|
|
501
|
+
} catch {
|
|
502
|
+
return null;
|
|
503
|
+
}
|
|
504
|
+
}
|
|
505
|
+
```
|
|
506
|
+
|
|
507
|
+
### Sync State Storage
|
|
508
|
+
|
|
509
|
+
```typescript
|
|
510
|
+
import YAML from 'yaml';
|
|
511
|
+
|
|
512
|
+
interface SyncStateFile {
|
|
513
|
+
version: 1;
|
|
514
|
+
issues: Record<number, {
|
|
515
|
+
github_updated_at: string;
|
|
516
|
+
crdt_frontiers: string; // base64 encoded
|
|
517
|
+
last_sync: string;
|
|
518
|
+
sync_status: SyncStatus;
|
|
519
|
+
}>;
|
|
520
|
+
metadata: {
|
|
521
|
+
labels_etag: string;
|
|
522
|
+
milestones_etag: string;
|
|
523
|
+
};
|
|
524
|
+
}
|
|
525
|
+
|
|
526
|
+
async function loadSyncState(): Promise<SyncStateFile> {
|
|
527
|
+
try {
|
|
528
|
+
const content = await readFile('.hardcopy/sync-state.yaml', 'utf-8');
|
|
529
|
+
return YAML.parse(content);
|
|
530
|
+
} catch {
|
|
531
|
+
return { version: 1, issues: {}, metadata: { labels_etag: '', milestones_etag: '' } };
|
|
532
|
+
}
|
|
533
|
+
}
|
|
534
|
+
|
|
535
|
+
async function saveSyncState(state: SyncStateFile): Promise<void> {
|
|
536
|
+
await writeFile('.hardcopy/sync-state.yaml', YAML.stringify(state));
|
|
537
|
+
}
|
|
538
|
+
```
|
|
539
|
+
|
|
540
|
+
---
|
|
541
|
+
|
|
542
|
+
## Generic Sync Plugin Architecture
|
|
543
|
+
|
|
544
|
+
### Base Types
|
|
545
|
+
|
|
546
|
+
```typescript
|
|
547
|
+
// Generic item that can be synced
|
|
548
|
+
interface SyncableItem {
|
|
549
|
+
id: string;
|
|
550
|
+
updatedAt: string;
|
|
551
|
+
data: unknown;
|
|
552
|
+
}
|
|
553
|
+
|
|
554
|
+
// Generic sync adapter
|
|
555
|
+
interface SyncAdapter<T extends SyncableItem> {
|
|
556
|
+
name: string;
|
|
557
|
+
|
|
558
|
+
// API operations
|
|
559
|
+
fetchAll(): AsyncGenerator<T>;
|
|
560
|
+
fetchOne(id: string): Promise<T>;
|
|
561
|
+
create(item: Omit<T, 'id' | 'updatedAt'>): Promise<T>;
|
|
562
|
+
update(id: string, updates: Partial<T>): Promise<T>;
|
|
563
|
+
delete(id: string): Promise<void>;
|
|
564
|
+
|
|
565
|
+
// CRDT conversion
|
|
566
|
+
toCRDT(item: T): Loro;
|
|
567
|
+
fromCRDT(doc: Loro): Partial<T>;
|
|
568
|
+
|
|
569
|
+
// File format
|
|
570
|
+
toFile(item: T, doc: Loro): string;
|
|
571
|
+
fromFile(content: string): { item: Partial<T>; doc: Loro };
|
|
572
|
+
}
|
|
573
|
+
```
|
|
574
|
+
|
|
575
|
+
### Example Adapters
|
|
576
|
+
|
|
577
|
+
```typescript
|
|
578
|
+
// GitHub Issues Adapter
|
|
579
|
+
class GitHubIssuesAdapter implements SyncAdapter<GitHubIssue> {
|
|
580
|
+
name = 'github-issues';
|
|
581
|
+
|
|
582
|
+
constructor(
|
|
583
|
+
private octokit: Octokit,
|
|
584
|
+
private owner: string,
|
|
585
|
+
private repo: string
|
|
586
|
+
) {}
|
|
587
|
+
|
|
588
|
+
async *fetchAll() {
|
|
589
|
+
for await (const response of this.octokit.paginate.iterator(
|
|
590
|
+
this.octokit.rest.issues.listForRepo,
|
|
591
|
+
{ owner: this.owner, repo: this.repo, state: 'all', per_page: 100 }
|
|
592
|
+
)) {
|
|
593
|
+
for (const issue of response.data) {
|
|
594
|
+
yield issue as GitHubIssue;
|
|
595
|
+
}
|
|
596
|
+
}
|
|
597
|
+
}
|
|
598
|
+
|
|
599
|
+
// ... other methods
|
|
600
|
+
}
|
|
601
|
+
|
|
602
|
+
// Figma Comments Adapter (example of extending to other services)
|
|
603
|
+
class FigmaCommentsAdapter implements SyncAdapter<FigmaComment> {
|
|
604
|
+
name = 'figma-comments';
|
|
605
|
+
|
|
606
|
+
constructor(private fileKey: string, private token: string) {}
|
|
607
|
+
|
|
608
|
+
async *fetchAll() {
|
|
609
|
+
const response = await fetch(
|
|
610
|
+
`https://api.figma.com/v1/files/${this.fileKey}/comments`,
|
|
611
|
+
{ headers: { 'X-Figma-Token': this.token } }
|
|
612
|
+
);
|
|
613
|
+
const data = await response.json();
|
|
614
|
+
yield* data.comments;
|
|
615
|
+
}
|
|
616
|
+
|
|
617
|
+
// ... other methods
|
|
618
|
+
}
|
|
619
|
+
```
|
|
620
|
+
|
|
621
|
+
---
|
|
622
|
+
|
|
623
|
+
## Performance Considerations
|
|
624
|
+
|
|
625
|
+
### Batching Updates
|
|
626
|
+
|
|
627
|
+
```typescript
|
|
628
|
+
// Don't commit after every change
|
|
629
|
+
function batchedUpdate(doc: Loro, operations: () => void): void {
|
|
630
|
+
// Run all operations
|
|
631
|
+
operations();
|
|
632
|
+
|
|
633
|
+
// Single commit at the end
|
|
634
|
+
doc.commit();
|
|
635
|
+
}
|
|
636
|
+
|
|
637
|
+
// Usage
|
|
638
|
+
batchedUpdate(doc, () => {
|
|
639
|
+
text.insert(0, 'Hello ');
|
|
640
|
+
text.insert(6, 'World');
|
|
641
|
+
list.push('item1');
|
|
642
|
+
list.push('item2');
|
|
643
|
+
});
|
|
644
|
+
```
|
|
645
|
+
|
|
646
|
+
### Lazy Loading
|
|
647
|
+
|
|
648
|
+
```typescript
|
|
649
|
+
// Don't load all CRDTs into memory at once
|
|
650
|
+
class LazyDocStore {
|
|
651
|
+
private cache = new Map<string, Loro>();
|
|
652
|
+
|
|
653
|
+
async get(key: string): Promise<Loro> {
|
|
654
|
+
if (!this.cache.has(key)) {
|
|
655
|
+
const doc = await loadCRDT(key);
|
|
656
|
+
this.cache.set(key, doc || new Loro());
|
|
657
|
+
}
|
|
658
|
+
return this.cache.get(key)!;
|
|
659
|
+
}
|
|
660
|
+
|
|
661
|
+
async save(key: string): Promise<void> {
|
|
662
|
+
const doc = this.cache.get(key);
|
|
663
|
+
if (doc) {
|
|
664
|
+
await saveCRDT(key, doc);
|
|
665
|
+
}
|
|
666
|
+
}
|
|
667
|
+
|
|
668
|
+
evict(key: string): void {
|
|
669
|
+
this.cache.delete(key);
|
|
670
|
+
}
|
|
671
|
+
}
|
|
672
|
+
```
|
|
673
|
+
|
|
674
|
+
### Delta Compression
|
|
675
|
+
|
|
676
|
+
```typescript
|
|
677
|
+
// Only store incremental updates after initial sync
|
|
678
|
+
class DeltaStorage {
|
|
679
|
+
private baseSnapshots = new Map<string, Uint8Array>();
|
|
680
|
+
private deltas = new Map<string, Uint8Array[]>();
|
|
681
|
+
|
|
682
|
+
async save(key: string, doc: Loro, isInitialSync: boolean): Promise<void> {
|
|
683
|
+
if (isInitialSync) {
|
|
684
|
+
this.baseSnapshots.set(key, doc.export({ mode: 'snapshot' }));
|
|
685
|
+
this.deltas.set(key, []);
|
|
686
|
+
} else {
|
|
687
|
+
const base = this.baseSnapshots.get(key)!;
|
|
688
|
+
const delta = doc.export({ mode: 'update', from: base });
|
|
689
|
+
this.deltas.get(key)!.push(delta);
|
|
690
|
+
}
|
|
691
|
+
}
|
|
692
|
+
|
|
693
|
+
async load(key: string): Promise<Loro> {
|
|
694
|
+
const doc = new Loro();
|
|
695
|
+
doc.import(this.baseSnapshots.get(key)!);
|
|
696
|
+
for (const delta of this.deltas.get(key)!) {
|
|
697
|
+
doc.import(delta);
|
|
698
|
+
}
|
|
699
|
+
return doc;
|
|
700
|
+
}
|
|
701
|
+
}
|
|
702
|
+
```
|
|
703
|
+
|
|
704
|
+
---
|
|
705
|
+
|
|
706
|
+
## Testing Sync Logic
|
|
707
|
+
|
|
708
|
+
### Simulating Concurrent Edits
|
|
709
|
+
|
|
710
|
+
```typescript
|
|
711
|
+
import { describe, it, expect } from 'vitest';
|
|
712
|
+
|
|
713
|
+
describe('CRDT sync', () => {
|
|
714
|
+
it('merges concurrent text edits', () => {
|
|
715
|
+
// Create two replicas
|
|
716
|
+
const doc1 = new Loro();
|
|
717
|
+
const doc2 = new Loro();
|
|
718
|
+
|
|
719
|
+
// Set peer IDs
|
|
720
|
+
doc1.setPeerId(1n);
|
|
721
|
+
doc2.setPeerId(2n);
|
|
722
|
+
|
|
723
|
+
// Initial sync
|
|
724
|
+
const text1 = doc1.getText('title');
|
|
725
|
+
text1.insert(0, 'Hello World');
|
|
726
|
+
doc1.commit();
|
|
727
|
+
doc2.import(doc1.export({ mode: 'snapshot' }));
|
|
728
|
+
|
|
729
|
+
// Concurrent edits
|
|
730
|
+
doc1.getText('title').insert(5, ' Beautiful'); // "Hello Beautiful World"
|
|
731
|
+
doc2.getText('title').insert(11, '!'); // "Hello World!"
|
|
732
|
+
doc1.commit();
|
|
733
|
+
doc2.commit();
|
|
734
|
+
|
|
735
|
+
// Merge
|
|
736
|
+
const updates1 = doc1.export({ mode: 'update' });
|
|
737
|
+
const updates2 = doc2.export({ mode: 'update' });
|
|
738
|
+
doc1.import(updates2);
|
|
739
|
+
doc2.import(updates1);
|
|
740
|
+
|
|
741
|
+
// Both should converge to same value
|
|
742
|
+
expect(doc1.toJSON().title).toBe(doc2.toJSON().title);
|
|
743
|
+
// Result: "Hello Beautiful World!" (both edits preserved)
|
|
744
|
+
});
|
|
745
|
+
});
|
|
746
|
+
```
|
|
747
|
+
|
|
748
|
+
---
|
|
749
|
+
|
|
750
|
+
## Error Recovery
|
|
751
|
+
|
|
752
|
+
### Handling Corrupted State
|
|
753
|
+
|
|
754
|
+
```typescript
|
|
755
|
+
async function recoverFromCorruption(issueNumber: number): Promise<Loro> {
|
|
756
|
+
// Try to load existing CRDT
|
|
757
|
+
const existing = await loadCRDT(issueNumber);
|
|
758
|
+
|
|
759
|
+
if (existing) {
|
|
760
|
+
try {
|
|
761
|
+
// Validate by exporting
|
|
762
|
+
existing.export({ mode: 'snapshot' });
|
|
763
|
+
return existing;
|
|
764
|
+
} catch (e) {
|
|
765
|
+
console.warn(`Corrupted CRDT for issue ${issueNumber}, rebuilding...`);
|
|
766
|
+
}
|
|
767
|
+
}
|
|
768
|
+
|
|
769
|
+
// Rebuild from remote
|
|
770
|
+
const issue = await fetchIssueFromGitHub(issueNumber);
|
|
771
|
+
const doc = new Loro();
|
|
772
|
+
initFromGitHub(doc, issue);
|
|
773
|
+
await saveCRDT(issueNumber, doc);
|
|
774
|
+
|
|
775
|
+
return doc;
|
|
776
|
+
}
|
|
777
|
+
```
|