@trestleinc/replicate 1.1.0 → 1.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/README.md +86 -149
- package/dist/index.js +1 -3
- package/package.json +1 -1
- package/src/client/persistence/sqlite.ts +4 -5
package/README.md
CHANGED
|
@@ -2,74 +2,64 @@
|
|
|
2
2
|
|
|
3
3
|
**Offline-first sync library using Yjs CRDTs and Convex for real-time data synchronization.**
|
|
4
4
|
|
|
5
|
-
Replicate provides a dual-storage architecture for building offline-capable applications with automatic conflict resolution. It combines Yjs CRDTs
|
|
6
|
-
|
|
7
|
-
## Features
|
|
8
|
-
|
|
9
|
-
- **Offline-first** - Works without internet, syncs when reconnected
|
|
10
|
-
- **Yjs CRDTs** - Automatic conflict-free replication with Yjs (96% smaller than Automerge, no WASM)
|
|
11
|
-
- **Real-time sync** - Convex WebSocket-based synchronization
|
|
12
|
-
- **TanStack DB integration** - Reactive state management for React and Svelte
|
|
13
|
-
- **Dual-storage pattern** - CRDT layer for conflict resolution + main tables for queries
|
|
14
|
-
- **Event sourcing** - Append-only event log preserves complete history
|
|
15
|
-
- **Type-safe** - Full TypeScript support
|
|
16
|
-
- **Multi-tab sync** - Changes sync instantly across browser tabs via TanStack coordination
|
|
17
|
-
- **SSR support** - Server-side rendering with data preloading
|
|
18
|
-
- **Network resilience** - Automatic retry with exponential backoff
|
|
19
|
-
- **Component-based** - Convex component for plug-and-play CRDT storage
|
|
20
|
-
- **Swappable persistence** - IndexedDB (browser), SQLite (React Native), or in-memory (testing)
|
|
21
|
-
- **React Native compatible** - SQLite persistence with y-op-sqlite and op-sqlite
|
|
22
|
-
- **Version history** - Create, list, restore, and delete document versions
|
|
23
|
-
- **Auto-compaction** - Size-based per-document compaction (no cron jobs needed)
|
|
5
|
+
Replicate provides a dual-storage architecture for building offline-capable applications with automatic conflict resolution. It combines Yjs CRDTs with TanStack DB's reactive state management and Convex's reactive backend for real-time synchronization and efficient querying.
|
|
6
|
+
|
|
24
7
|
|
|
25
8
|
## Architecture
|
|
26
9
|
|
|
27
|
-
### Data Flow
|
|
10
|
+
### Data Flow
|
|
28
11
|
|
|
29
12
|
```mermaid
|
|
30
13
|
sequenceDiagram
|
|
31
|
-
participant
|
|
32
|
-
participant
|
|
33
|
-
participant TDB as TanStack DB
|
|
14
|
+
participant UI as React Component
|
|
15
|
+
participant Collection as TanStack DB Collection
|
|
34
16
|
participant Yjs as Yjs CRDT
|
|
35
|
-
participant
|
|
36
|
-
participant Convex as Convex
|
|
17
|
+
participant Storage as Local Storage<br/>(IndexedDB/SQLite)
|
|
18
|
+
participant Convex as Convex Backend
|
|
37
19
|
participant Table as Main Table
|
|
38
20
|
|
|
39
|
-
|
|
40
|
-
UI->>
|
|
41
|
-
|
|
42
|
-
Yjs
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
Note over
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
Convex->>
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
UI-->>User: Re-render with synced data
|
|
21
|
+
Note over UI,Storage: Client-side (offline-capable)
|
|
22
|
+
UI->>Collection: insert/update/delete
|
|
23
|
+
Collection->>Yjs: Apply change to Y.Doc
|
|
24
|
+
Yjs->>Storage: Persist locally
|
|
25
|
+
Collection-->>UI: Re-render (optimistic)
|
|
26
|
+
|
|
27
|
+
Note over Collection,Convex: Sync layer
|
|
28
|
+
Collection->>Convex: Send CRDT delta
|
|
29
|
+
Convex->>Convex: Append to event log
|
|
30
|
+
Convex->>Table: Update materialized doc
|
|
31
|
+
|
|
32
|
+
Note over Convex,UI: Real-time updates
|
|
33
|
+
Table-->>Collection: stream subscription
|
|
34
|
+
Collection-->>UI: Re-render with server state
|
|
54
35
|
```
|
|
55
36
|
|
|
56
|
-
### Dual-Storage
|
|
37
|
+
### Dual-Storage Pattern
|
|
57
38
|
|
|
58
39
|
```mermaid
|
|
59
|
-
graph
|
|
60
|
-
Client
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
40
|
+
graph TB
|
|
41
|
+
subgraph Client
|
|
42
|
+
TDB[TanStack DB]
|
|
43
|
+
Yjs[Yjs CRDT]
|
|
44
|
+
Local[(IndexedDB/SQLite)]
|
|
45
|
+
TDB <--> Yjs
|
|
46
|
+
Yjs <--> Local
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
subgraph Convex
|
|
50
|
+
Component[(Event Log<br/>CRDT Deltas)]
|
|
51
|
+
Main[(Main Table<br/>Materialized Docs)]
|
|
52
|
+
Component --> Main
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
Yjs -->|insert/update/remove| Component
|
|
56
|
+
Main -->|stream subscription| TDB
|
|
67
57
|
```
|
|
68
58
|
|
|
69
|
-
**Why
|
|
70
|
-
- **
|
|
71
|
-
- **Main
|
|
72
|
-
- Similar to CQRS
|
|
59
|
+
**Why dual storage?**
|
|
60
|
+
- **Event Log (Component)**: Append-only CRDT deltas for conflict resolution and history
|
|
61
|
+
- **Main Table**: Materialized current state for efficient queries and indexes
|
|
62
|
+
- Similar to CQRS: event log = write model, main table = read model
|
|
73
63
|
|
|
74
64
|
## Installation
|
|
75
65
|
|
|
@@ -115,7 +105,7 @@ export default defineSchema({
|
|
|
115
105
|
tasks: schema.table(
|
|
116
106
|
{
|
|
117
107
|
// Your application fields only!
|
|
118
|
-
//
|
|
108
|
+
// timestamp is automatically injected by schema.table()
|
|
119
109
|
id: v.string(),
|
|
120
110
|
text: v.string(),
|
|
121
111
|
isCompleted: v.boolean(),
|
|
@@ -128,7 +118,6 @@ export default defineSchema({
|
|
|
128
118
|
```
|
|
129
119
|
|
|
130
120
|
**What `schema.table()` does:**
|
|
131
|
-
- Automatically injects `version: v.number()` (for CRDT versioning)
|
|
132
121
|
- Automatically injects `timestamp: v.number()` (for incremental sync)
|
|
133
122
|
- You only define your business logic fields
|
|
134
123
|
|
|
@@ -151,10 +140,10 @@ const r = replicate(components.replicate);
|
|
|
151
140
|
export const {
|
|
152
141
|
stream,
|
|
153
142
|
material,
|
|
143
|
+
recovery,
|
|
154
144
|
insert,
|
|
155
145
|
update,
|
|
156
146
|
remove,
|
|
157
|
-
versions
|
|
158
147
|
} = r<Task>({
|
|
159
148
|
collection: 'tasks',
|
|
160
149
|
compaction: { threshold: 5_000_000 }, // Optional: size threshold for auto-compaction (default: 5MB)
|
|
@@ -163,12 +152,12 @@ export const {
|
|
|
163
152
|
|
|
164
153
|
**What `replicate()` generates:**
|
|
165
154
|
|
|
166
|
-
- `stream` - Real-time CRDT stream query (
|
|
155
|
+
- `stream` - Real-time CRDT stream query (checkpoint-based subscriptions)
|
|
167
156
|
- `material` - SSR-friendly query (for server-side rendering)
|
|
157
|
+
- `recovery` - State vector sync query (for startup reconciliation)
|
|
168
158
|
- `insert` - Dual-storage insert mutation (auto-compacts when threshold exceeded)
|
|
169
159
|
- `update` - Dual-storage update mutation (auto-compacts when threshold exceeded)
|
|
170
160
|
- `remove` - Dual-storage delete mutation (auto-compacts when threshold exceeded)
|
|
171
|
-
- `versions` - Version history APIs (create, list, get, restore, remove)
|
|
172
161
|
|
|
173
162
|
### Step 4: Create a Custom Hook
|
|
174
163
|
|
|
@@ -315,6 +304,41 @@ function TasksPage() {
|
|
|
315
304
|
|
|
316
305
|
**Note:** If your framework doesn't support SSR, the collection works just fine without `initialData` - it will fetch data on mount and show a loading state.
|
|
317
306
|
|
|
307
|
+
## Sync Protocol
|
|
308
|
+
|
|
309
|
+
Replicate uses two complementary sync mechanisms:
|
|
310
|
+
|
|
311
|
+
### `stream` - Real-time Checkpoint Sync
|
|
312
|
+
|
|
313
|
+
The primary sync mechanism for real-time updates. Uses checkpoint-based incremental sync:
|
|
314
|
+
|
|
315
|
+
1. Client subscribes with last known checkpoint (timestamp)
|
|
316
|
+
2. Server returns all deltas since that checkpoint
|
|
317
|
+
3. Client applies deltas and updates checkpoint
|
|
318
|
+
4. Subscription stays open for live updates
|
|
319
|
+
|
|
320
|
+
This is efficient for ongoing sync but requires the server to have deltas going back to the client's checkpoint.
|
|
321
|
+
|
|
322
|
+
### `recovery` - State Vector Sync
|
|
323
|
+
|
|
324
|
+
Used on startup to reconcile client and server state using Yjs state vectors:
|
|
325
|
+
|
|
326
|
+
1. Client encodes its local Y.Doc state vector (compact representation of what it has)
|
|
327
|
+
2. Server merges all snapshots + deltas into full state
|
|
328
|
+
3. Server computes diff between its state and client's state vector
|
|
329
|
+
4. Server returns only the missing bytes
|
|
330
|
+
5. Client applies the diff to catch up
|
|
331
|
+
|
|
332
|
+
**When recovery is used:**
|
|
333
|
+
- App startup (before stream subscription begins)
|
|
334
|
+
- After extended offline periods
|
|
335
|
+
- When checkpoint-based sync can't satisfy the request (deltas compacted)
|
|
336
|
+
|
|
337
|
+
**Why both?**
|
|
338
|
+
- `stream` is optimized for real-time (small checkpoint, fast subscription)
|
|
339
|
+
- `recovery` handles cold starts and large gaps efficiently (state vectors)
|
|
340
|
+
- Together they ensure clients always sync correctly regardless of history
|
|
341
|
+
|
|
318
342
|
## Delete Pattern: Hard Delete with Event History
|
|
319
343
|
|
|
320
344
|
Replicate uses **hard deletes** where items are physically removed from the main table, while the internal component preserves complete event history.
|
|
@@ -370,10 +394,10 @@ const r = replicate(components.replicate);
|
|
|
370
394
|
export const {
|
|
371
395
|
stream,
|
|
372
396
|
material,
|
|
397
|
+
recovery,
|
|
373
398
|
insert,
|
|
374
399
|
update,
|
|
375
400
|
remove,
|
|
376
|
-
versions
|
|
377
401
|
} = r<Task>({
|
|
378
402
|
collection: 'tasks',
|
|
379
403
|
|
|
@@ -392,16 +416,12 @@ export const {
|
|
|
392
416
|
const userId = await ctx.auth.getUserIdentity();
|
|
393
417
|
if (!userId) throw new Error('Unauthorized');
|
|
394
418
|
},
|
|
395
|
-
evalVersion: async (ctx, collection, documentId) => { /* auth for versioning */ },
|
|
396
|
-
evalRestore: async (ctx, collection, documentId, versionId) => { /* auth for restore */ },
|
|
397
419
|
|
|
398
420
|
// Lifecycle callbacks (on* hooks run AFTER execution)
|
|
399
421
|
onStream: async (ctx, result) => { /* after stream query */ },
|
|
400
422
|
onInsert: async (ctx, doc) => { /* after insert */ },
|
|
401
423
|
onUpdate: async (ctx, doc) => { /* after update */ },
|
|
402
424
|
onRemove: async (ctx, documentId) => { /* after remove */ },
|
|
403
|
-
onVersion: async (ctx, result) => { /* after version created */ },
|
|
404
|
-
onRestore: async (ctx, result) => { /* after restore */ },
|
|
405
425
|
|
|
406
426
|
// Transform hook (modify documents before returning)
|
|
407
427
|
transform: async (docs) => docs.filter(d => d.isPublic),
|
|
@@ -434,47 +454,6 @@ const plainText = prose.extract(notebook.content);
|
|
|
434
454
|
const binding = await collection.utils.prose(notebookId, 'content');
|
|
435
455
|
```
|
|
436
456
|
|
|
437
|
-
### Version History
|
|
438
|
-
|
|
439
|
-
Create and manage document version history:
|
|
440
|
-
|
|
441
|
-
```typescript
|
|
442
|
-
// convex/tasks.ts
|
|
443
|
-
export const { versions } = replicate<Task>({
|
|
444
|
-
collection: 'tasks',
|
|
445
|
-
});
|
|
446
|
-
|
|
447
|
-
// Create a version
|
|
448
|
-
await ctx.runMutation(api.tasks.versions.create, {
|
|
449
|
-
documentId: 'task-123',
|
|
450
|
-
label: 'Before major edit',
|
|
451
|
-
createdBy: 'user-456',
|
|
452
|
-
});
|
|
453
|
-
|
|
454
|
-
// List versions
|
|
455
|
-
const versionList = await ctx.runQuery(api.tasks.versions.list, {
|
|
456
|
-
documentId: 'task-123',
|
|
457
|
-
limit: 10,
|
|
458
|
-
});
|
|
459
|
-
|
|
460
|
-
// Get a specific version
|
|
461
|
-
const version = await ctx.runQuery(api.tasks.versions.get, {
|
|
462
|
-
versionId: 'version-789',
|
|
463
|
-
});
|
|
464
|
-
|
|
465
|
-
// Restore a version
|
|
466
|
-
await ctx.runMutation(api.tasks.versions.restore, {
|
|
467
|
-
documentId: 'task-123',
|
|
468
|
-
versionId: 'version-789',
|
|
469
|
-
createBackup: true, // Optional: create backup before restore
|
|
470
|
-
});
|
|
471
|
-
|
|
472
|
-
// Delete a version
|
|
473
|
-
await ctx.runMutation(api.tasks.versions.remove, {
|
|
474
|
-
versionId: 'version-789',
|
|
475
|
-
});
|
|
476
|
-
```
|
|
477
|
-
|
|
478
457
|
### Persistence Providers
|
|
479
458
|
|
|
480
459
|
Choose the right storage backend for your platform:
|
|
@@ -676,16 +655,12 @@ interface ReplicateConfig<T> {
|
|
|
676
655
|
evalRead?: (ctx, collection) => Promise<void>;
|
|
677
656
|
evalWrite?: (ctx, doc) => Promise<void>;
|
|
678
657
|
evalRemove?: (ctx, documentId) => Promise<void>;
|
|
679
|
-
evalVersion?: (ctx, collection, documentId) => Promise<void>;
|
|
680
|
-
evalRestore?: (ctx, collection, documentId, versionId) => Promise<void>;
|
|
681
658
|
|
|
682
659
|
// Lifecycle callbacks (run after operation)
|
|
683
660
|
onStream?: (ctx, result) => Promise<void>;
|
|
684
661
|
onInsert?: (ctx, doc) => Promise<void>;
|
|
685
662
|
onUpdate?: (ctx, doc) => Promise<void>;
|
|
686
663
|
onRemove?: (ctx, documentId) => Promise<void>;
|
|
687
|
-
onVersion?: (ctx, result) => Promise<void>;
|
|
688
|
-
onRestore?: (ctx, result) => Promise<void>;
|
|
689
664
|
|
|
690
665
|
// Transform hook (modify documents before returning)
|
|
691
666
|
transform?: (docs) => Promise<T[]>;
|
|
@@ -694,16 +669,16 @@ interface ReplicateConfig<T> {
|
|
|
694
669
|
```
|
|
695
670
|
|
|
696
671
|
**Returns:** Object with generated functions:
|
|
697
|
-
- `stream` - Real-time CRDT stream query
|
|
672
|
+
- `stream` - Real-time CRDT stream query (checkpoint-based)
|
|
698
673
|
- `material` - SSR-friendly query for hydration
|
|
674
|
+
- `recovery` - State vector sync query (for startup reconciliation)
|
|
699
675
|
- `insert` - Dual-storage insert mutation (auto-compacts when threshold exceeded)
|
|
700
676
|
- `update` - Dual-storage update mutation (auto-compacts when threshold exceeded)
|
|
701
677
|
- `remove` - Dual-storage delete mutation (auto-compacts when threshold exceeded)
|
|
702
|
-
- `versions` - Version history APIs (create, list, get, restore, remove)
|
|
703
678
|
|
|
704
679
|
#### `schema.table(userFields, applyIndexes?)`
|
|
705
680
|
|
|
706
|
-
Automatically inject
|
|
681
|
+
Automatically inject `timestamp` field for incremental sync.
|
|
707
682
|
|
|
708
683
|
**Parameters:**
|
|
709
684
|
- `userFields` - User's business logic fields
|
|
@@ -737,44 +712,6 @@ Validator for ProseMirror-compatible JSON fields.
|
|
|
737
712
|
content: schema.prose() // Validates ProseMirror JSON structure
|
|
738
713
|
```
|
|
739
714
|
|
|
740
|
-
## Performance
|
|
741
|
-
|
|
742
|
-
### Storage Performance
|
|
743
|
-
|
|
744
|
-
- **Swappable persistence** - IndexedDB (browser), SQLite (React Native), or in-memory (testing)
|
|
745
|
-
- **Yjs** CRDT operations are extremely fast (96% smaller than Automerge)
|
|
746
|
-
- **TanStack DB** provides optimistic updates and reactive state management
|
|
747
|
-
- **Indexed queries** in Convex for fast incremental sync
|
|
748
|
-
|
|
749
|
-
### Sync Performance
|
|
750
|
-
|
|
751
|
-
- **Real-time updates** - WebSocket-based change notifications
|
|
752
|
-
- **Delta encoding** - Only send what changed (< 1KB per change vs 100KB+ full state)
|
|
753
|
-
- **Event sourcing** - Append-only writes, no update conflicts
|
|
754
|
-
- **Optimistic UI** - Instant updates without waiting for server
|
|
755
|
-
|
|
756
|
-
### Multi-Tab Sync
|
|
757
|
-
|
|
758
|
-
- **TanStack coordination** - Built-in multi-tab sync via BroadcastChannel
|
|
759
|
-
- **Yjs shared state** - Single source of truth per browser
|
|
760
|
-
- **Leader election** - Only one tab runs sync operations
|
|
761
|
-
|
|
762
|
-
## Offline Behavior
|
|
763
|
-
|
|
764
|
-
### How It Works
|
|
765
|
-
|
|
766
|
-
- **Writes** - Queue locally in Yjs CRDT, sync when online
|
|
767
|
-
- **Reads** - Always work from local TanStack DB cache (instant!)
|
|
768
|
-
- **UI** - Fully functional with optimistic updates
|
|
769
|
-
- **Conflicts** - Auto-resolved by Yjs CRDTs (conflict-free!)
|
|
770
|
-
|
|
771
|
-
### Network Resilience
|
|
772
|
-
|
|
773
|
-
- Automatic retry with exponential backoff
|
|
774
|
-
- Network error detection (fetch errors, connection issues)
|
|
775
|
-
- Queue changes while offline
|
|
776
|
-
- Graceful degradation
|
|
777
|
-
|
|
778
715
|
## Examples
|
|
779
716
|
|
|
780
717
|
### Interval - Linear-style Issue Tracker
|
package/dist/index.js
CHANGED
|
@@ -1477,9 +1477,7 @@ class SqlitePersistenceProvider {
|
|
|
1477
1477
|
whenSynced;
|
|
1478
1478
|
constructor(collection, _ydoc, leveldb){
|
|
1479
1479
|
this.persistence = leveldb;
|
|
1480
|
-
this.whenSynced = this.persistence.getYDoc(collection).then((storedDoc)=>{
|
|
1481
|
-
storedDoc.store;
|
|
1482
|
-
});
|
|
1480
|
+
this.whenSynced = this.persistence.getYDoc(collection).then((storedDoc)=>{});
|
|
1483
1481
|
}
|
|
1484
1482
|
destroy() {
|
|
1485
1483
|
this.persistence.destroy();
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@trestleinc/replicate",
|
|
3
|
-
"version": "1.1.
|
|
3
|
+
"version": "1.1.1",
|
|
4
4
|
"description": "Offline-first data replication with Yjs CRDTs and Convex",
|
|
5
5
|
"repository": "github:trestleinc/replicate",
|
|
6
6
|
"homepage": "https://github.com/trestleinc/replicate#readme",
|
|
@@ -78,11 +78,10 @@ class SqlitePersistenceProvider implements PersistenceProvider {
|
|
|
78
78
|
|
|
79
79
|
constructor(collection: string, _ydoc: Y.Doc, leveldb: LeveldbPersistence) {
|
|
80
80
|
this.persistence = leveldb;
|
|
81
|
-
// Load existing document state
|
|
82
|
-
this.whenSynced = this.persistence.getYDoc(collection).then((storedDoc: Y.Doc) => {
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
if (state) {
|
|
81
|
+
// Load existing document state (may be null for new collections)
|
|
82
|
+
this.whenSynced = this.persistence.getYDoc(collection).then((storedDoc: Y.Doc | null) => {
|
|
83
|
+
if (storedDoc) {
|
|
84
|
+
// Apply stored state to provided ydoc
|
|
86
85
|
// The stored doc and ydoc are merged via y-leveldb's internal mechanisms
|
|
87
86
|
}
|
|
88
87
|
});
|