@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 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 (96% smaller than Automerge, no WASM) with TanStack DB's reactive state management and Convex's reactive backend for real-time synchronization and efficient querying.
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: Real-Time Sync
10
+ ### Data Flow
28
11
 
29
12
  ```mermaid
30
13
  sequenceDiagram
31
- participant User
32
- participant UI as React/Svelte Component
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 Offline as Offline Executor
36
- participant Convex as Convex Component
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
- User->>UI: Create/Update Task
40
- UI->>TDB: collection.insert/update
41
- TDB->>Yjs: Update Yjs CRDT
42
- Yjs-->>TDB: Notify change
43
- TDB-->>UI: Re-render (optimistic)
44
-
45
- Note over Offline: Automatic retry with backoff
46
- Offline->>Yjs: Get CRDT delta
47
- Offline->>Convex: insert/update mutation
48
- Convex->>Component: Append delta to event log
49
- Convex->>Table: Insert/Update materialized doc
50
-
51
- Note over Convex,Table: Change detected
52
- Table-->>UI: Subscription update
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 Architecture
37
+ ### Dual-Storage Pattern
57
38
 
58
39
  ```mermaid
59
- graph LR
60
- Client[Client<br/>Yjs CRDT]
61
- Component[Component Storage<br/>Event Log<br/>CRDT Deltas]
62
- MainTable[Main Application Table<br/>Materialized Docs<br/>Efficient Queries]
63
-
64
- Client -->|insert/update/remove| Component
65
- Component -->|also writes to| MainTable
66
- MainTable -->|subscription| Client
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 both?**
70
- - **Component Storage (Event Log)**: Append-only CRDT deltas, complete history, conflict resolution
71
- - **Main Tables (Read Model)**: Current state, efficient server-side queries, indexes, and reactive subscriptions
72
- - Similar to CQRS/Event Sourcing: component = event log, main table = materialized view
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
- // version and timestamp are automatically injected by schema.table()
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 (for client subscriptions)
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 replication metadata fields (`version`, `timestamp`).
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.0",
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
- // Apply stored state to provided ydoc
84
- const state = storedDoc.store;
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
  });