@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.
Files changed (53) hide show
  1. package/.eslintrc.json +22 -0
  2. package/.github/workflows/publish.yml +41 -0
  3. package/.prettierignore +17 -0
  4. package/LICENSE +21 -0
  5. package/README.md +183 -0
  6. package/dist/cli.d.ts +1 -0
  7. package/dist/cli.js +2950 -0
  8. package/dist/index.d.ts +406 -0
  9. package/dist/index.js +2737 -0
  10. package/dist/mcp-server.d.ts +7 -0
  11. package/dist/mcp-server.js +2665 -0
  12. package/docs/research/crdt.md +777 -0
  13. package/docs/research/github-issues.md +684 -0
  14. package/docs/research/gql.md +876 -0
  15. package/docs/research/index.md +19 -0
  16. package/docs/specs/conflict-resolution.md +1254 -0
  17. package/docs/specs/hardcopy.md +742 -0
  18. package/docs/specs/patchwork-integration.md +227 -0
  19. package/docs/specs/plugin-architecture.md +747 -0
  20. package/mcp.json +8 -0
  21. package/package.json +64 -0
  22. package/scripts/install-graphqlite.ts +156 -0
  23. package/src/cli.ts +356 -0
  24. package/src/config.ts +104 -0
  25. package/src/conflict-store.ts +136 -0
  26. package/src/conflict.ts +147 -0
  27. package/src/crdt.ts +100 -0
  28. package/src/db.ts +600 -0
  29. package/src/env.ts +34 -0
  30. package/src/format.ts +72 -0
  31. package/src/formats/github-issue.ts +55 -0
  32. package/src/hardcopy/core.ts +78 -0
  33. package/src/hardcopy/diff.ts +188 -0
  34. package/src/hardcopy/index.ts +67 -0
  35. package/src/hardcopy/init.ts +24 -0
  36. package/src/hardcopy/push.ts +444 -0
  37. package/src/hardcopy/sync.ts +37 -0
  38. package/src/hardcopy/types.ts +49 -0
  39. package/src/hardcopy/views.ts +199 -0
  40. package/src/hardcopy.ts +1 -0
  41. package/src/index.ts +13 -0
  42. package/src/llm-merge.ts +109 -0
  43. package/src/mcp-server.ts +388 -0
  44. package/src/merge.ts +75 -0
  45. package/src/provider.ts +40 -0
  46. package/src/providers/a2a/index.ts +166 -0
  47. package/src/providers/git/index.ts +212 -0
  48. package/src/providers/github/index.ts +236 -0
  49. package/src/providers/github/issues.ts +66 -0
  50. package/src/providers.ts +7 -0
  51. package/src/types.ts +101 -0
  52. package/tsconfig.json +21 -0
  53. 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
+ ```