@trestleinc/replicate 1.1.0 → 1.1.2-preview.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/README.md +446 -260
- package/dist/client/index.d.ts +311 -19
- package/dist/client/index.js +4027 -0
- package/dist/component/_generated/api.d.ts +13 -17
- package/dist/component/_generated/api.js +24 -4
- package/dist/component/_generated/component.d.ts +79 -77
- package/dist/component/_generated/component.js +1 -0
- package/dist/component/_generated/dataModel.d.ts +12 -15
- package/dist/component/_generated/dataModel.js +1 -0
- package/dist/component/_generated/server.d.ts +19 -22
- package/dist/component/_generated/server.js +65 -1
- package/dist/component/_virtual/rolldown_runtime.js +18 -0
- package/dist/component/convex.config.d.ts +6 -2
- package/dist/component/convex.config.js +7 -3
- package/dist/component/logger.d.ts +10 -6
- package/dist/component/logger.js +25 -28
- package/dist/component/public.d.ts +70 -61
- package/dist/component/public.js +311 -295
- package/dist/component/schema.d.ts +53 -45
- package/dist/component/schema.js +26 -32
- package/dist/component/shared/types.d.ts +9 -0
- package/dist/component/shared/types.js +15 -0
- package/dist/server/index.d.ts +134 -13
- package/dist/server/index.js +368 -0
- package/dist/shared/index.d.ts +27 -3
- package/dist/shared/index.js +1 -2
- package/package.json +34 -29
- package/src/client/collection.ts +339 -306
- package/src/client/errors.ts +9 -9
- package/src/client/index.ts +13 -32
- package/src/client/logger.ts +2 -2
- package/src/client/merge.ts +37 -34
- package/src/client/persistence/custom.ts +84 -0
- package/src/client/persistence/index.ts +9 -46
- package/src/client/persistence/indexeddb.ts +111 -84
- package/src/client/persistence/memory.ts +3 -3
- package/src/client/persistence/sqlite/browser.ts +168 -0
- package/src/client/persistence/sqlite/native.ts +29 -0
- package/src/client/persistence/sqlite/schema.ts +124 -0
- package/src/client/persistence/types.ts +32 -28
- package/src/client/prose-schema.ts +55 -0
- package/src/client/prose.ts +28 -25
- package/src/client/replicate.ts +5 -5
- package/src/client/services/cursor.ts +109 -0
- package/src/component/_generated/component.ts +31 -29
- package/src/component/convex.config.ts +2 -2
- package/src/component/logger.ts +7 -7
- package/src/component/public.ts +225 -237
- package/src/component/schema.ts +18 -15
- package/src/server/builder.ts +20 -7
- package/src/server/index.ts +3 -5
- package/src/server/schema.ts +5 -5
- package/src/server/storage.ts +113 -59
- package/src/shared/index.ts +5 -5
- package/src/shared/types.ts +51 -14
- package/dist/client/collection.d.ts +0 -96
- package/dist/client/errors.d.ts +0 -59
- package/dist/client/logger.d.ts +0 -2
- package/dist/client/merge.d.ts +0 -77
- package/dist/client/persistence/adapters/index.d.ts +0 -8
- package/dist/client/persistence/adapters/opsqlite.d.ts +0 -46
- package/dist/client/persistence/adapters/sqljs.d.ts +0 -83
- package/dist/client/persistence/index.d.ts +0 -49
- package/dist/client/persistence/indexeddb.d.ts +0 -17
- package/dist/client/persistence/memory.d.ts +0 -16
- package/dist/client/persistence/sqlite-browser.d.ts +0 -51
- package/dist/client/persistence/sqlite-level.d.ts +0 -63
- package/dist/client/persistence/sqlite-rn.d.ts +0 -36
- package/dist/client/persistence/sqlite.d.ts +0 -47
- package/dist/client/persistence/types.d.ts +0 -42
- package/dist/client/prose.d.ts +0 -56
- package/dist/client/replicate.d.ts +0 -40
- package/dist/client/services/checkpoint.d.ts +0 -18
- package/dist/client/services/reconciliation.d.ts +0 -24
- package/dist/index.js +0 -1620
- package/dist/server/builder.d.ts +0 -94
- package/dist/server/schema.d.ts +0 -27
- package/dist/server/storage.d.ts +0 -80
- package/dist/server.js +0 -281
- package/dist/shared/types.d.ts +0 -50
- package/dist/shared/types.js +0 -6
- package/dist/shared.js +0 -6
- package/src/client/persistence/adapters/index.ts +0 -8
- package/src/client/persistence/adapters/opsqlite.ts +0 -54
- package/src/client/persistence/adapters/sqljs.ts +0 -128
- package/src/client/persistence/sqlite-browser.ts +0 -107
- package/src/client/persistence/sqlite-level.ts +0 -407
- package/src/client/persistence/sqlite-rn.ts +0 -44
- package/src/client/persistence/sqlite.ts +0 -161
- package/src/client/services/checkpoint.ts +0 -86
- package/src/client/services/reconciliation.ts +0 -108
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/>(SQLite)
|
|
18
|
+
participant Convex as Convex Backend
|
|
37
19
|
participant Table as Main Table
|
|
38
20
|
|
|
39
|
-
|
|
40
|
-
UI->>
|
|
41
|
-
|
|
42
|
-
Yjs
|
|
43
|
-
|
|
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)
|
|
44
26
|
|
|
45
|
-
Note over
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
Convex->>
|
|
49
|
-
Convex->>Table: Insert/Update materialized doc
|
|
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
|
|
50
31
|
|
|
51
|
-
Note over Convex,
|
|
52
|
-
Table-->>
|
|
53
|
-
UI
|
|
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[(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,25 +105,24 @@ 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(),
|
|
122
112
|
},
|
|
123
113
|
(t) => t
|
|
124
|
-
.index('
|
|
114
|
+
.index('by_doc_id', ['id']) // Required for document lookups
|
|
125
115
|
.index('by_timestamp', ['timestamp']) // Required for incremental sync
|
|
126
116
|
),
|
|
127
117
|
});
|
|
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
|
|
|
135
124
|
**Required indexes:**
|
|
136
|
-
- `
|
|
125
|
+
- `by_doc_id` on `['id']` - Enables fast document lookups during updates
|
|
137
126
|
- `by_timestamp` on `['timestamp']` - Enables efficient incremental synchronization
|
|
138
127
|
|
|
139
128
|
### Step 3: Create Replication Functions
|
|
@@ -151,77 +140,97 @@ 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
|
-
|
|
147
|
+
mark, // Peer sync progress tracking
|
|
148
|
+
compact, // Manual compaction trigger
|
|
158
149
|
} = r<Task>({
|
|
159
150
|
collection: 'tasks',
|
|
160
|
-
compaction: {
|
|
151
|
+
compaction: {
|
|
152
|
+
sizeThreshold: "5mb", // Type-safe size: "100kb", "5mb", "1gb"
|
|
153
|
+
peerTimeout: "24h", // Type-safe duration: "30m", "24h", "7d"
|
|
154
|
+
},
|
|
161
155
|
});
|
|
162
156
|
```
|
|
163
157
|
|
|
164
158
|
**What `replicate()` generates:**
|
|
165
159
|
|
|
166
|
-
- `stream` - Real-time CRDT stream query (
|
|
160
|
+
- `stream` - Real-time CRDT stream query (cursor-based subscriptions with `seq` numbers)
|
|
167
161
|
- `material` - SSR-friendly query (for server-side rendering)
|
|
162
|
+
- `recovery` - State vector sync query (for startup reconciliation)
|
|
168
163
|
- `insert` - Dual-storage insert mutation (auto-compacts when threshold exceeded)
|
|
169
164
|
- `update` - Dual-storage update mutation (auto-compacts when threshold exceeded)
|
|
170
165
|
- `remove` - Dual-storage delete mutation (auto-compacts when threshold exceeded)
|
|
171
|
-
- `
|
|
166
|
+
- `mark` - Report sync progress to server (peer tracking for safe compaction)
|
|
167
|
+
- `compact` - Manual compaction trigger (peer-aware, respects active peer sync state)
|
|
172
168
|
|
|
173
|
-
### Step 4:
|
|
169
|
+
### Step 4: Define Your Collection
|
|
174
170
|
|
|
175
|
-
Create a
|
|
171
|
+
Create a collection definition using `collection.create()`. This is SSR-safe because persistence and config are deferred until `init()` is called in the browser:
|
|
176
172
|
|
|
177
173
|
```typescript
|
|
178
|
-
// src/
|
|
179
|
-
import {
|
|
180
|
-
import {
|
|
181
|
-
import { api } from '
|
|
182
|
-
import
|
|
183
|
-
import {
|
|
174
|
+
// src/collections/tasks.ts
|
|
175
|
+
import { collection, persistence } from '@trestleinc/replicate/client';
|
|
176
|
+
import { ConvexClient } from 'convex/browser';
|
|
177
|
+
import { api } from '../../convex/_generated/api';
|
|
178
|
+
import initSqlJs from 'sql.js';
|
|
179
|
+
import { z } from 'zod';
|
|
184
180
|
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
181
|
+
// Define your Zod schema (required)
|
|
182
|
+
const taskSchema = z.object({
|
|
183
|
+
id: z.string(),
|
|
184
|
+
text: z.string(),
|
|
185
|
+
isCompleted: z.boolean(),
|
|
186
|
+
});
|
|
190
187
|
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
) {
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
);
|
|
209
|
-
}
|
|
210
|
-
return tasksCollection;
|
|
211
|
-
}, [initialData]);
|
|
212
|
-
}
|
|
188
|
+
export type Task = z.infer<typeof taskSchema>;
|
|
189
|
+
|
|
190
|
+
// Create lazy-initialized collection (SSR-safe)
|
|
191
|
+
export const tasks = collection.create({
|
|
192
|
+
// Async factory - only called in browser during init()
|
|
193
|
+
persistence: async () => {
|
|
194
|
+
const SQL = await initSqlJs({ locateFile: (f) => `/${f}` });
|
|
195
|
+
return persistence.sqlite.browser(SQL, 'tasks');
|
|
196
|
+
},
|
|
197
|
+
// Sync factory - only called in browser during init()
|
|
198
|
+
config: () => ({
|
|
199
|
+
schema: taskSchema,
|
|
200
|
+
convexClient: new ConvexClient(import.meta.env.VITE_CONVEX_URL),
|
|
201
|
+
api: api.tasks,
|
|
202
|
+
getKey: (task) => task.id,
|
|
203
|
+
}),
|
|
204
|
+
});
|
|
213
205
|
```
|
|
214
206
|
|
|
215
|
-
|
|
207
|
+
**Key points:**
|
|
208
|
+
- `collection.create()` returns a lazy collection that's safe to import during SSR
|
|
209
|
+
- `persistence` and `config` are factory functions, not values - they're only called during `init()`
|
|
210
|
+
- `schema` is required (Zod schema for type inference and prose field detection)
|
|
211
|
+
- Collection name is auto-extracted from `api.tasks` function path
|
|
212
|
+
|
|
213
|
+
### Step 5: Initialize and Use in Components
|
|
214
|
+
|
|
215
|
+
Initialize the collection once in your app's entry point (browser only), then use it in components:
|
|
216
216
|
|
|
217
217
|
```typescript
|
|
218
|
-
// src/routes/
|
|
218
|
+
// src/routes/__root.tsx (or app entry point)
|
|
219
|
+
import { tasks } from '../collections/tasks';
|
|
220
|
+
|
|
221
|
+
// Initialize once during app startup (browser only)
|
|
222
|
+
// For SSR frameworks, do this in a client-side effect or loader
|
|
223
|
+
await tasks.init();
|
|
224
|
+
```
|
|
225
|
+
|
|
226
|
+
```typescript
|
|
227
|
+
// src/components/TaskList.tsx
|
|
219
228
|
import { useLiveQuery } from '@tanstack/react-db';
|
|
220
|
-
import {
|
|
229
|
+
import { tasks, type Task } from '../collections/tasks';
|
|
221
230
|
|
|
222
231
|
export function TaskList() {
|
|
223
|
-
const collection =
|
|
224
|
-
const { data:
|
|
232
|
+
const collection = tasks.get();
|
|
233
|
+
const { data: taskList, isLoading, isError } = useLiveQuery(collection);
|
|
225
234
|
|
|
226
235
|
const handleCreate = () => {
|
|
227
236
|
collection.insert({
|
|
@@ -254,7 +263,7 @@ export function TaskList() {
|
|
|
254
263
|
<div>
|
|
255
264
|
<button onClick={handleCreate}>Add Task</button>
|
|
256
265
|
|
|
257
|
-
{
|
|
266
|
+
{taskList.map((task) => (
|
|
258
267
|
<div key={task.id}>
|
|
259
268
|
<input
|
|
260
269
|
type="checkbox"
|
|
@@ -270,9 +279,14 @@ export function TaskList() {
|
|
|
270
279
|
}
|
|
271
280
|
```
|
|
272
281
|
|
|
282
|
+
**Lifecycle:**
|
|
283
|
+
1. `collection.create()` - Define collection (module-level, SSR-safe)
|
|
284
|
+
2. `await tasks.init()` - Initialize persistence and config (browser only, call once)
|
|
285
|
+
3. `tasks.get()` - Get the TanStack DB collection instance (after init)
|
|
286
|
+
|
|
273
287
|
### Step 6: Server-Side Rendering (Recommended)
|
|
274
288
|
|
|
275
|
-
For frameworks that support SSR (TanStack Start, Next.js, Remix, SvelteKit), preloading data on the server
|
|
289
|
+
For frameworks that support SSR (TanStack Start, Next.js, Remix, SvelteKit), preloading data on the server enables instant page loads.
|
|
276
290
|
|
|
277
291
|
**Why SSR is recommended:**
|
|
278
292
|
- **Instant page loads** - No loading spinners on first render
|
|
@@ -280,40 +294,136 @@ For frameworks that support SSR (TanStack Start, Next.js, Remix, SvelteKit), pre
|
|
|
280
294
|
- **Reduced client work** - Data already available on hydration
|
|
281
295
|
- **Seamless transition** - Real-time sync takes over after hydration
|
|
282
296
|
|
|
283
|
-
**Step 1:
|
|
284
|
-
|
|
285
|
-
The `material` query is automatically generated by `replicate()` and returns all documents for SSR hydration.
|
|
297
|
+
**Step 1: Prefetch material on the server**
|
|
286
298
|
|
|
287
|
-
|
|
299
|
+
Use `ConvexHttpClient` to fetch data during SSR. The `material` query is generated by `replicate()`:
|
|
288
300
|
|
|
289
301
|
```typescript
|
|
290
|
-
// src/routes/
|
|
291
|
-
import {
|
|
302
|
+
// TanStack Start: src/routes/__root.tsx
|
|
303
|
+
import { createRootRoute } from '@tanstack/react-router';
|
|
292
304
|
import { ConvexHttpClient } from 'convex/browser';
|
|
293
305
|
import { api } from '../convex/_generated/api';
|
|
294
|
-
import type { Task } from '../useTasks';
|
|
295
306
|
|
|
296
307
|
const httpClient = new ConvexHttpClient(import.meta.env.VITE_CONVEX_URL);
|
|
297
308
|
|
|
298
|
-
export const Route =
|
|
309
|
+
export const Route = createRootRoute({
|
|
299
310
|
loader: async () => {
|
|
300
|
-
const
|
|
301
|
-
return {
|
|
311
|
+
const tasksMaterial = await httpClient.query(api.tasks.material);
|
|
312
|
+
return { tasksMaterial };
|
|
302
313
|
},
|
|
303
314
|
});
|
|
315
|
+
```
|
|
304
316
|
|
|
305
|
-
|
|
306
|
-
|
|
317
|
+
```typescript
|
|
318
|
+
// SvelteKit: src/routes/+layout.server.ts
|
|
319
|
+
import { ConvexHttpClient } from 'convex/browser';
|
|
320
|
+
import { api } from '../convex/_generated/api';
|
|
321
|
+
import { PUBLIC_CONVEX_URL } from '$env/static/public';
|
|
307
322
|
|
|
308
|
-
|
|
309
|
-
const collection = useTasks(initialTasks);
|
|
310
|
-
const { data: tasks } = useLiveQuery(collection);
|
|
323
|
+
const httpClient = new ConvexHttpClient(PUBLIC_CONVEX_URL);
|
|
311
324
|
|
|
312
|
-
|
|
325
|
+
export async function load() {
|
|
326
|
+
const tasksMaterial = await httpClient.query(api.tasks.material);
|
|
327
|
+
return { tasksMaterial };
|
|
313
328
|
}
|
|
314
329
|
```
|
|
315
330
|
|
|
316
|
-
**
|
|
331
|
+
**Step 2: Pass material to init() on the client**
|
|
332
|
+
|
|
333
|
+
```typescript
|
|
334
|
+
// TanStack Start: src/routes/__root.tsx (client component)
|
|
335
|
+
import { tasks } from '../collections/tasks';
|
|
336
|
+
|
|
337
|
+
function RootComponent() {
|
|
338
|
+
const { tasksMaterial } = Route.useLoaderData();
|
|
339
|
+
|
|
340
|
+
useEffect(() => {
|
|
341
|
+
// Initialize with SSR data - no loading state!
|
|
342
|
+
tasks.init(tasksMaterial);
|
|
343
|
+
}, []);
|
|
344
|
+
|
|
345
|
+
return <Outlet />;
|
|
346
|
+
}
|
|
347
|
+
```
|
|
348
|
+
|
|
349
|
+
```svelte
|
|
350
|
+
<!-- SvelteKit: src/routes/+layout.svelte -->
|
|
351
|
+
<script lang="ts">
|
|
352
|
+
import { tasks } from '../collections/tasks';
|
|
353
|
+
import { onMount } from 'svelte';
|
|
354
|
+
|
|
355
|
+
export let data; // From +layout.server.ts
|
|
356
|
+
|
|
357
|
+
onMount(async () => {
|
|
358
|
+
await tasks.init(data.tasksMaterial);
|
|
359
|
+
});
|
|
360
|
+
</script>
|
|
361
|
+
```
|
|
362
|
+
|
|
363
|
+
**Note:** If your framework doesn't support SSR, just call `await tasks.init()` without arguments - it will fetch data on mount and show a loading state.
|
|
364
|
+
|
|
365
|
+
## Sync Protocol
|
|
366
|
+
|
|
367
|
+
Replicate uses cursor-based sync with peer tracking for safe compaction.
|
|
368
|
+
|
|
369
|
+
### `stream` - Cursor-Based Real-Time Sync
|
|
370
|
+
|
|
371
|
+
The primary sync mechanism uses monotonically increasing sequence numbers (`seq`):
|
|
372
|
+
|
|
373
|
+
1. Client subscribes with last known `cursor` (seq number)
|
|
374
|
+
2. Server returns all changes with `seq > cursor`
|
|
375
|
+
3. Client applies changes and updates local cursor
|
|
376
|
+
4. Client calls `mark` to report sync progress to server
|
|
377
|
+
5. Subscription stays open for live updates
|
|
378
|
+
|
|
379
|
+
This approach enables:
|
|
380
|
+
- **Safe compaction**: Server knows which deltas each peer has synced
|
|
381
|
+
- **Peer tracking**: Active peers are tracked via `mark` calls
|
|
382
|
+
- **No data loss**: Compaction only removes deltas all active peers have received
|
|
383
|
+
|
|
384
|
+
### `mark` - Peer Sync Tracking
|
|
385
|
+
|
|
386
|
+
Clients report their sync progress to the server:
|
|
387
|
+
|
|
388
|
+
```typescript
|
|
389
|
+
// Called automatically after applying changes
|
|
390
|
+
await convexClient.mutation(api.tasks.mark, {
|
|
391
|
+
peerId: "client-uuid",
|
|
392
|
+
syncedSeq: 42, // Last processed seq number
|
|
393
|
+
});
|
|
394
|
+
```
|
|
395
|
+
|
|
396
|
+
The server tracks:
|
|
397
|
+
- Which peers are actively syncing
|
|
398
|
+
- Each peer's last synced `seq` number
|
|
399
|
+
- Peer timeout for cleanup (configurable via `peerTimeout`)
|
|
400
|
+
|
|
401
|
+
### `compact` - Peer-Aware Compaction
|
|
402
|
+
|
|
403
|
+
Compaction is safe because it respects peer sync state:
|
|
404
|
+
|
|
405
|
+
1. Server checks minimum `syncedSeq` across all active peers
|
|
406
|
+
2. Only deletes deltas where `seq < minSyncedSeq`
|
|
407
|
+
3. Ensures no active peer loses data they haven't synced
|
|
408
|
+
|
|
409
|
+
**Compaction triggers:**
|
|
410
|
+
- **Automatic**: When document deltas exceed `sizeThreshold`
|
|
411
|
+
- **Manual**: Via `compact` mutation
|
|
412
|
+
|
|
413
|
+
### `recovery` - State Vector Sync
|
|
414
|
+
|
|
415
|
+
Used on startup to reconcile client and server state using Yjs state vectors:
|
|
416
|
+
|
|
417
|
+
1. Client encodes its local Y.Doc state vector (compact representation of what it has)
|
|
418
|
+
2. Server merges all snapshots + deltas into full state
|
|
419
|
+
3. Server computes diff between its state and client's state vector
|
|
420
|
+
4. Server returns only the missing bytes
|
|
421
|
+
5. Client applies the diff to catch up
|
|
422
|
+
|
|
423
|
+
**When recovery is used:**
|
|
424
|
+
- App startup (before stream subscription begins)
|
|
425
|
+
- After extended offline periods
|
|
426
|
+
- When cursor-based sync can't satisfy the request (deltas compacted)
|
|
317
427
|
|
|
318
428
|
## Delete Pattern: Hard Delete with Event History
|
|
319
429
|
|
|
@@ -370,10 +480,12 @@ const r = replicate(components.replicate);
|
|
|
370
480
|
export const {
|
|
371
481
|
stream,
|
|
372
482
|
material,
|
|
483
|
+
recovery,
|
|
373
484
|
insert,
|
|
374
485
|
update,
|
|
375
486
|
remove,
|
|
376
|
-
|
|
487
|
+
mark,
|
|
488
|
+
compact,
|
|
377
489
|
} = r<Task>({
|
|
378
490
|
collection: 'tasks',
|
|
379
491
|
|
|
@@ -392,16 +504,22 @@ export const {
|
|
|
392
504
|
const userId = await ctx.auth.getUserIdentity();
|
|
393
505
|
if (!userId) throw new Error('Unauthorized');
|
|
394
506
|
},
|
|
395
|
-
|
|
396
|
-
|
|
507
|
+
evalMark: async (ctx, peerId) => {
|
|
508
|
+
// Validate peer identity
|
|
509
|
+
const userId = await ctx.auth.getUserIdentity();
|
|
510
|
+
if (!userId) throw new Error('Unauthorized');
|
|
511
|
+
},
|
|
512
|
+
evalCompact: async (ctx, documentId) => {
|
|
513
|
+
// Restrict compaction to admin users
|
|
514
|
+
const userId = await ctx.auth.getUserIdentity();
|
|
515
|
+
if (!userId) throw new Error('Unauthorized');
|
|
516
|
+
},
|
|
397
517
|
|
|
398
518
|
// Lifecycle callbacks (on* hooks run AFTER execution)
|
|
399
519
|
onStream: async (ctx, result) => { /* after stream query */ },
|
|
400
520
|
onInsert: async (ctx, doc) => { /* after insert */ },
|
|
401
521
|
onUpdate: async (ctx, doc) => { /* after update */ },
|
|
402
522
|
onRemove: async (ctx, documentId) => { /* after remove */ },
|
|
403
|
-
onVersion: async (ctx, result) => { /* after version created */ },
|
|
404
|
-
onRestore: async (ctx, result) => { /* after restore */ },
|
|
405
523
|
|
|
406
524
|
// Transform hook (modify documents before returning)
|
|
407
525
|
transform: async (docs) => docs.filter(d => d.isPublic),
|
|
@@ -434,91 +552,84 @@ const plainText = prose.extract(notebook.content);
|
|
|
434
552
|
const binding = await collection.utils.prose(notebookId, 'content');
|
|
435
553
|
```
|
|
436
554
|
|
|
437
|
-
###
|
|
555
|
+
### Persistence Providers
|
|
438
556
|
|
|
439
|
-
|
|
557
|
+
Choose the right storage backend for your platform. Persistence is configured in the `persistence` factory of `collection.create()`:
|
|
440
558
|
|
|
441
559
|
```typescript
|
|
442
|
-
|
|
443
|
-
export const { versions } = replicate<Task>({
|
|
444
|
-
collection: 'tasks',
|
|
445
|
-
});
|
|
560
|
+
import { collection, persistence } from '@trestleinc/replicate/client';
|
|
446
561
|
|
|
447
|
-
//
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
const versionList = await ctx.runQuery(api.tasks.versions.list, {
|
|
456
|
-
documentId: 'task-123',
|
|
457
|
-
limit: 10,
|
|
562
|
+
// Browser SQLite: Uses sql.js WASM with OPFS persistence
|
|
563
|
+
export const tasks = collection.create({
|
|
564
|
+
persistence: async () => {
|
|
565
|
+
const initSqlJs = (await import('sql.js')).default;
|
|
566
|
+
const SQL = await initSqlJs({ locateFile: (f) => `/${f}` });
|
|
567
|
+
return persistence.sqlite.browser(SQL, 'my-app-db');
|
|
568
|
+
},
|
|
569
|
+
config: () => ({ /* ... */ }),
|
|
458
570
|
});
|
|
459
571
|
|
|
460
|
-
//
|
|
461
|
-
const
|
|
462
|
-
|
|
572
|
+
// React Native SQLite: Uses op-sqlite (native SQLite)
|
|
573
|
+
export const tasks = collection.create({
|
|
574
|
+
persistence: async () => {
|
|
575
|
+
const { open } = await import('@op-engineering/op-sqlite');
|
|
576
|
+
const db = open({ name: 'my-app-db' });
|
|
577
|
+
return persistence.sqlite.native(db, 'my-app-db');
|
|
578
|
+
},
|
|
579
|
+
config: () => ({ /* ... */ }),
|
|
463
580
|
});
|
|
464
581
|
|
|
465
|
-
//
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
createBackup: true, // Optional: create backup before restore
|
|
582
|
+
// Testing: In-memory (no persistence)
|
|
583
|
+
export const tasks = collection.create({
|
|
584
|
+
persistence: async () => persistence.memory(),
|
|
585
|
+
config: () => ({ /* ... */ }),
|
|
470
586
|
});
|
|
471
587
|
|
|
472
|
-
//
|
|
473
|
-
|
|
474
|
-
|
|
588
|
+
// Custom backend: Implement StorageAdapter interface
|
|
589
|
+
export const tasks = collection.create({
|
|
590
|
+
persistence: async () => persistence.custom(new MyCustomAdapter()),
|
|
591
|
+
config: () => ({ /* ... */ }),
|
|
475
592
|
});
|
|
476
593
|
```
|
|
477
594
|
|
|
478
|
-
|
|
595
|
+
**SQLite Browser** - Uses sql.js (SQLite compiled to WASM) with OPFS persistence. You initialize sql.js yourself and pass the SQL object.
|
|
479
596
|
|
|
480
|
-
|
|
597
|
+
**SQLite Native** - Uses op-sqlite for React Native. You create the database and pass it.
|
|
481
598
|
|
|
482
|
-
|
|
483
|
-
import { persistence, adapters } from '@trestleinc/replicate/client';
|
|
599
|
+
**Memory** - No persistence, useful for testing.
|
|
484
600
|
|
|
485
|
-
|
|
486
|
-
convexCollectionOptions<Task>({
|
|
487
|
-
// ... other options
|
|
488
|
-
persistence: persistence.indexeddb(),
|
|
489
|
-
});
|
|
601
|
+
**Custom** - Implement `StorageAdapter` for any storage backend.
|
|
490
602
|
|
|
491
|
-
|
|
492
|
-
// You initialize sql.js and pass the SQL object
|
|
493
|
-
import initSqlJs from 'sql.js';
|
|
494
|
-
const SQL = await initSqlJs({ locateFile: (file) => `/${file}` });
|
|
495
|
-
convexCollectionOptions<Task>({
|
|
496
|
-
// ... other options
|
|
497
|
-
persistence: await persistence.sqlite.browser(SQL, 'my-app-db'),
|
|
498
|
-
});
|
|
603
|
+
### Custom Storage Backends
|
|
499
604
|
|
|
500
|
-
|
|
501
|
-
import { open } from '@op-engineering/op-sqlite';
|
|
502
|
-
const db = open({ name: 'my-app-db' });
|
|
503
|
-
convexCollectionOptions<Task>({
|
|
504
|
-
// ... other options
|
|
505
|
-
persistence: await persistence.sqlite.native(db, 'my-app-db'),
|
|
506
|
-
});
|
|
605
|
+
Implement `StorageAdapter` for custom storage (Chrome extensions, localStorage, cloud storage):
|
|
507
606
|
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
// ... other options
|
|
511
|
-
persistence: persistence.memory(),
|
|
512
|
-
});
|
|
513
|
-
```
|
|
607
|
+
```typescript
|
|
608
|
+
import { persistence, type StorageAdapter } from '@trestleinc/replicate/client';
|
|
514
609
|
|
|
515
|
-
|
|
610
|
+
class ChromeStorageAdapter implements StorageAdapter {
|
|
611
|
+
async get(key: string): Promise<Uint8Array | undefined> {
|
|
612
|
+
const result = await chrome.storage.local.get(key);
|
|
613
|
+
return result[key] ? new Uint8Array(result[key]) : undefined;
|
|
614
|
+
}
|
|
516
615
|
|
|
517
|
-
|
|
616
|
+
async set(key: string, value: Uint8Array): Promise<void> {
|
|
617
|
+
await chrome.storage.local.set({ [key]: Array.from(value) });
|
|
618
|
+
}
|
|
518
619
|
|
|
519
|
-
|
|
620
|
+
async delete(key: string): Promise<void> {
|
|
621
|
+
await chrome.storage.local.remove(key);
|
|
622
|
+
}
|
|
623
|
+
|
|
624
|
+
async keys(prefix: string): Promise<string[]> {
|
|
625
|
+
const all = await chrome.storage.local.get(null);
|
|
626
|
+
return Object.keys(all).filter(k => k.startsWith(prefix));
|
|
627
|
+
}
|
|
628
|
+
}
|
|
520
629
|
|
|
521
|
-
|
|
630
|
+
// Use custom adapter
|
|
631
|
+
const chromePersistence = persistence.custom(new ChromeStorageAdapter());
|
|
632
|
+
```
|
|
522
633
|
|
|
523
634
|
### Logging Configuration
|
|
524
635
|
|
|
@@ -544,42 +655,90 @@ await configure({
|
|
|
544
655
|
|
|
545
656
|
### Client-Side (`@trestleinc/replicate/client`)
|
|
546
657
|
|
|
547
|
-
#### `
|
|
658
|
+
#### `collection.create({ persistence, config })`
|
|
548
659
|
|
|
549
|
-
Creates collection
|
|
660
|
+
Creates a lazy-initialized collection with deferred persistence and config resolution. Both `persistence` and `config` are factory functions that are only called when `init()` is invoked (browser-only).
|
|
550
661
|
|
|
551
|
-
**
|
|
662
|
+
**Parameters:**
|
|
663
|
+
- `persistence` - Async factory function that returns a `Persistence` instance
|
|
664
|
+
- `config` - Sync factory function that returns the collection config (ConvexClient, schema, api, etc.)
|
|
665
|
+
|
|
666
|
+
**Returns:** `LazyCollection` with `init(material?)` and `get()` methods
|
|
667
|
+
|
|
668
|
+
**Example:**
|
|
552
669
|
```typescript
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
670
|
+
import { collection, persistence } from '@trestleinc/replicate/client';
|
|
671
|
+
import { ConvexClient } from 'convex/browser';
|
|
672
|
+
import initSqlJs from 'sql.js';
|
|
673
|
+
|
|
674
|
+
export const tasks = collection.create({
|
|
675
|
+
persistence: async () => {
|
|
676
|
+
const SQL = await initSqlJs({ locateFile: (f) => `/${f}` });
|
|
677
|
+
return persistence.sqlite.browser(SQL, 'tasks');
|
|
678
|
+
},
|
|
679
|
+
config: () => ({
|
|
680
|
+
schema: taskSchema,
|
|
681
|
+
convexClient: new ConvexClient(import.meta.env.VITE_CONVEX_URL),
|
|
682
|
+
api: api.tasks,
|
|
683
|
+
getKey: (task) => task.id,
|
|
684
|
+
}),
|
|
685
|
+
});
|
|
686
|
+
|
|
687
|
+
// In your app initialization (browser only):
|
|
688
|
+
// Pass SSR-prefetched material for instant hydration
|
|
689
|
+
await tasks.init(material);
|
|
690
|
+
const collection = tasks.get();
|
|
691
|
+
```
|
|
692
|
+
|
|
693
|
+
**SSR Prefetch (server-side):**
|
|
694
|
+
```typescript
|
|
695
|
+
// SvelteKit: +layout.server.ts
|
|
696
|
+
import { ConvexHttpClient } from 'convex/browser';
|
|
697
|
+
const httpClient = new ConvexHttpClient(PUBLIC_CONVEX_URL);
|
|
698
|
+
|
|
699
|
+
export async function load() {
|
|
700
|
+
const material = await httpClient.query(api.tasks.material);
|
|
701
|
+
return { material };
|
|
702
|
+
}
|
|
703
|
+
```
|
|
704
|
+
|
|
705
|
+
#### Collection Config Options
|
|
706
|
+
|
|
707
|
+
The `config` factory in `collection.create()` accepts these options:
|
|
708
|
+
|
|
709
|
+
```typescript
|
|
710
|
+
interface CollectionConfig<T> {
|
|
711
|
+
schema: ZodObject; // Required: Zod schema for type inference
|
|
712
|
+
getKey: (item: T) => string | number; // Extract unique key from item
|
|
713
|
+
convexClient: ConvexClient; // Convex client instance
|
|
714
|
+
api: { // API from replicate()
|
|
715
|
+
stream: FunctionReference; // Real-time subscription
|
|
557
716
|
insert: FunctionReference; // Insert mutation
|
|
558
717
|
update: FunctionReference; // Update mutation
|
|
559
718
|
remove: FunctionReference; // Delete mutation
|
|
719
|
+
recovery: FunctionReference; // State vector sync
|
|
720
|
+
mark: FunctionReference; // Peer sync tracking
|
|
721
|
+
compact: FunctionReference; // Manual compaction
|
|
722
|
+
material?: FunctionReference; // SSR hydration query
|
|
560
723
|
};
|
|
561
|
-
collection: string;
|
|
562
|
-
getKey: (item: T) => string | number;
|
|
563
|
-
persistence?: Persistence; // Optional: defaults to indexeddbPersistence()
|
|
564
|
-
material?: Materialized<T>; // SSR hydration data
|
|
565
|
-
prose?: Array<keyof T>; // Optional: prose fields for rich text
|
|
566
724
|
undoCaptureTimeout?: number; // Undo stack merge window (default: 500ms)
|
|
567
725
|
}
|
|
568
726
|
```
|
|
569
727
|
|
|
570
|
-
**Returns:** Collection options for `createCollection()`
|
|
571
|
-
|
|
572
728
|
**Example:**
|
|
573
729
|
```typescript
|
|
574
|
-
const
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
|
|
730
|
+
export const tasks = collection.create({
|
|
731
|
+
persistence: async () => {
|
|
732
|
+
const SQL = await initSqlJs({ locateFile: (f) => `/${f}` });
|
|
733
|
+
return persistence.sqlite.browser(SQL, 'tasks');
|
|
734
|
+
},
|
|
735
|
+
config: () => ({
|
|
736
|
+
schema: taskSchema,
|
|
579
737
|
getKey: (task) => task.id,
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
)
|
|
738
|
+
convexClient: new ConvexClient(import.meta.env.VITE_CONVEX_URL),
|
|
739
|
+
api: api.tasks,
|
|
740
|
+
}),
|
|
741
|
+
});
|
|
583
742
|
```
|
|
584
743
|
|
|
585
744
|
#### `prose.extract(proseJson)`
|
|
@@ -601,35 +760,54 @@ const plainText = prose.extract(task.content);
|
|
|
601
760
|
#### Persistence Providers
|
|
602
761
|
|
|
603
762
|
```typescript
|
|
604
|
-
import { persistence,
|
|
763
|
+
import { persistence, type StorageAdapter } from '@trestleinc/replicate/client';
|
|
605
764
|
|
|
606
|
-
// Persistence providers
|
|
607
|
-
persistence.indexeddb() // Browser: IndexedDB (default)
|
|
765
|
+
// Persistence providers (use in collection.create persistence factory)
|
|
608
766
|
persistence.sqlite.browser(SQL, name) // Browser: sql.js WASM + OPFS
|
|
609
767
|
persistence.sqlite.native(db, name) // React Native: op-sqlite
|
|
610
|
-
persistence.memory()
|
|
611
|
-
|
|
612
|
-
// SQLite adapters (for advanced use)
|
|
613
|
-
adapters.sqljs // SqlJsAdapter class for browser
|
|
614
|
-
adapters.opsqlite // OPSqliteAdapter class for React Native
|
|
768
|
+
persistence.memory() // Testing: in-memory (no persistence)
|
|
769
|
+
persistence.custom(adapter) // Custom: your StorageAdapter implementation
|
|
615
770
|
```
|
|
616
771
|
|
|
617
|
-
**`persistence.indexeddb()`** - Browser-only, uses y-indexeddb + browser-level.
|
|
618
|
-
|
|
619
772
|
**`persistence.sqlite.browser(SQL, name)`** - Browser SQLite using sql.js WASM. You initialize sql.js and pass the SQL object.
|
|
620
773
|
|
|
621
774
|
**`persistence.sqlite.native(db, name)`** - React Native SQLite using op-sqlite. You create the database and pass it.
|
|
622
775
|
|
|
623
776
|
**`persistence.memory()`** - In-memory, no persistence. Useful for testing.
|
|
624
777
|
|
|
778
|
+
**`persistence.custom(adapter)`** - Custom storage backend. Pass your `StorageAdapter` implementation.
|
|
779
|
+
|
|
780
|
+
#### `StorageAdapter` Interface
|
|
781
|
+
|
|
782
|
+
Implement for custom storage backends:
|
|
783
|
+
|
|
784
|
+
```typescript
|
|
785
|
+
interface StorageAdapter {
|
|
786
|
+
/** Get value by key, returns undefined if not found */
|
|
787
|
+
get(key: string): Promise<Uint8Array | undefined>;
|
|
788
|
+
|
|
789
|
+
/** Set value by key */
|
|
790
|
+
set(key: string, value: Uint8Array): Promise<void>;
|
|
791
|
+
|
|
792
|
+
/** Delete value by key */
|
|
793
|
+
delete(key: string): Promise<void>;
|
|
794
|
+
|
|
795
|
+
/** List all keys matching prefix */
|
|
796
|
+
keys(prefix: string): Promise<string[]>;
|
|
797
|
+
|
|
798
|
+
/** Optional: cleanup when persistence is destroyed */
|
|
799
|
+
close?(): void;
|
|
800
|
+
}
|
|
801
|
+
```
|
|
802
|
+
|
|
625
803
|
#### Error Classes
|
|
626
804
|
|
|
627
805
|
```typescript
|
|
628
806
|
import { errors } from '@trestleinc/replicate/client';
|
|
629
807
|
|
|
630
808
|
errors.Network // Network-related failures
|
|
631
|
-
errors.IDB //
|
|
632
|
-
errors.IDBWrite //
|
|
809
|
+
errors.IDB // Storage read errors
|
|
810
|
+
errors.IDBWrite // Storage write errors
|
|
633
811
|
errors.Reconciliation // Phantom document cleanup errors
|
|
634
812
|
errors.Prose // Rich text field errors
|
|
635
813
|
errors.CollectionNotReady// Collection not initialized
|
|
@@ -665,9 +843,10 @@ Configuration for the bound replicate function.
|
|
|
665
843
|
interface ReplicateConfig<T> {
|
|
666
844
|
collection: string; // Collection name (e.g., 'tasks')
|
|
667
845
|
|
|
668
|
-
// Optional:
|
|
846
|
+
// Optional: Compaction settings with type-safe values
|
|
669
847
|
compaction?: {
|
|
670
|
-
|
|
848
|
+
sizeThreshold?: Size; // Size threshold: "100kb", "5mb", "1gb" (default: "5mb")
|
|
849
|
+
peerTimeout?: Duration; // Peer timeout: "30m", "24h", "7d" (default: "24h")
|
|
671
850
|
};
|
|
672
851
|
|
|
673
852
|
// Optional: Hooks for permissions and lifecycle
|
|
@@ -676,16 +855,14 @@ interface ReplicateConfig<T> {
|
|
|
676
855
|
evalRead?: (ctx, collection) => Promise<void>;
|
|
677
856
|
evalWrite?: (ctx, doc) => Promise<void>;
|
|
678
857
|
evalRemove?: (ctx, documentId) => Promise<void>;
|
|
679
|
-
|
|
680
|
-
|
|
858
|
+
evalMark?: (ctx, peerId) => Promise<void>;
|
|
859
|
+
evalCompact?: (ctx, documentId) => Promise<void>;
|
|
681
860
|
|
|
682
861
|
// Lifecycle callbacks (run after operation)
|
|
683
862
|
onStream?: (ctx, result) => Promise<void>;
|
|
684
863
|
onInsert?: (ctx, doc) => Promise<void>;
|
|
685
864
|
onUpdate?: (ctx, doc) => Promise<void>;
|
|
686
865
|
onRemove?: (ctx, documentId) => Promise<void>;
|
|
687
|
-
onVersion?: (ctx, result) => Promise<void>;
|
|
688
|
-
onRestore?: (ctx, result) => Promise<void>;
|
|
689
866
|
|
|
690
867
|
// Transform hook (modify documents before returning)
|
|
691
868
|
transform?: (docs) => Promise<T[]>;
|
|
@@ -693,17 +870,23 @@ interface ReplicateConfig<T> {
|
|
|
693
870
|
}
|
|
694
871
|
```
|
|
695
872
|
|
|
873
|
+
**Type-safe values:**
|
|
874
|
+
- `Size`: `"100kb"`, `"5mb"`, `"1gb"`, etc.
|
|
875
|
+
- `Duration`: `"30m"`, `"24h"`, `"7d"`, etc.
|
|
876
|
+
|
|
696
877
|
**Returns:** Object with generated functions:
|
|
697
|
-
- `stream` - Real-time CRDT stream query
|
|
878
|
+
- `stream` - Real-time CRDT stream query (cursor-based with `seq` numbers)
|
|
698
879
|
- `material` - SSR-friendly query for hydration
|
|
880
|
+
- `recovery` - State vector sync query (for startup reconciliation)
|
|
699
881
|
- `insert` - Dual-storage insert mutation (auto-compacts when threshold exceeded)
|
|
700
882
|
- `update` - Dual-storage update mutation (auto-compacts when threshold exceeded)
|
|
701
883
|
- `remove` - Dual-storage delete mutation (auto-compacts when threshold exceeded)
|
|
702
|
-
- `
|
|
884
|
+
- `mark` - Peer sync tracking mutation (reports `syncedSeq` to server)
|
|
885
|
+
- `compact` - Manual compaction mutation (peer-aware, safe for active clients)
|
|
703
886
|
|
|
704
887
|
#### `schema.table(userFields, applyIndexes?)`
|
|
705
888
|
|
|
706
|
-
Automatically inject
|
|
889
|
+
Automatically inject `timestamp` field for incremental sync.
|
|
707
890
|
|
|
708
891
|
**Parameters:**
|
|
709
892
|
- `userFields` - User's business logic fields
|
|
@@ -721,7 +904,7 @@ tasks: schema.table(
|
|
|
721
904
|
text: v.string(),
|
|
722
905
|
},
|
|
723
906
|
(t) => t
|
|
724
|
-
.index('
|
|
907
|
+
.index('by_doc_id', ['id'])
|
|
725
908
|
.index('by_timestamp', ['timestamp'])
|
|
726
909
|
)
|
|
727
910
|
```
|
|
@@ -737,43 +920,38 @@ Validator for ProseMirror-compatible JSON fields.
|
|
|
737
920
|
content: schema.prose() // Validates ProseMirror JSON structure
|
|
738
921
|
```
|
|
739
922
|
|
|
740
|
-
|
|
741
|
-
|
|
742
|
-
### Storage Performance
|
|
923
|
+
### Shared Types (`@trestleinc/replicate/shared`)
|
|
743
924
|
|
|
744
|
-
|
|
745
|
-
|
|
746
|
-
- **TanStack DB** provides optimistic updates and reactive state management
|
|
747
|
-
- **Indexed queries** in Convex for fast incremental sync
|
|
925
|
+
```typescript
|
|
926
|
+
import type { ProseValue } from '@trestleinc/replicate/shared';
|
|
748
927
|
|
|
749
|
-
|
|
928
|
+
// ProseValue - branded type for prose fields in Zod schemas
|
|
929
|
+
// Use the prose() helper from client to create fields of this type
|
|
930
|
+
```
|
|
750
931
|
|
|
751
|
-
|
|
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
|
|
932
|
+
## React Native
|
|
755
933
|
|
|
756
|
-
|
|
934
|
+
React Native doesn't include the Web Crypto API by default. Install these polyfills:
|
|
757
935
|
|
|
758
|
-
|
|
759
|
-
|
|
760
|
-
|
|
936
|
+
```bash
|
|
937
|
+
npm install react-native-get-random-values react-native-random-uuid
|
|
938
|
+
```
|
|
761
939
|
|
|
762
|
-
|
|
940
|
+
Import them at the **very top** of your app's entry point (before any other imports):
|
|
763
941
|
|
|
764
|
-
|
|
942
|
+
```javascript
|
|
943
|
+
// index.js or app/_layout.tsx - MUST be first!
|
|
944
|
+
import "react-native-get-random-values";
|
|
945
|
+
import "react-native-random-uuid";
|
|
765
946
|
|
|
766
|
-
|
|
767
|
-
|
|
768
|
-
- **UI** - Fully functional with optimistic updates
|
|
769
|
-
- **Conflicts** - Auto-resolved by Yjs CRDTs (conflict-free!)
|
|
947
|
+
// Then your other imports...
|
|
948
|
+
```
|
|
770
949
|
|
|
771
|
-
|
|
950
|
+
This provides:
|
|
951
|
+
- `crypto.getRandomValues()` - Required by Yjs for CRDT operations
|
|
952
|
+
- `crypto.randomUUID()` - Used for generating document and peer IDs
|
|
772
953
|
|
|
773
|
-
|
|
774
|
-
- Network error detection (fetch errors, connection issues)
|
|
775
|
-
- Queue changes while offline
|
|
776
|
-
- Graceful degradation
|
|
954
|
+
See [`examples/expo/`](./examples/expo/) for a complete React Native example using Expo.
|
|
777
955
|
|
|
778
956
|
## Examples
|
|
779
957
|
|
|
@@ -781,21 +959,29 @@ content: schema.prose() // Validates ProseMirror JSON structure
|
|
|
781
959
|
|
|
782
960
|
A full-featured offline-first issue tracker built with Replicate, demonstrating real-world usage patterns.
|
|
783
961
|
|
|
784
|
-
|
|
962
|
+
**Live Demo:** [interval.robelest.com](https://interval.robelest.com)
|
|
785
963
|
|
|
786
|
-
|
|
964
|
+
**Source Code:** Available in three framework variants:
|
|
965
|
+
- [`examples/tanstack-start/`](./examples/tanstack-start/) - TanStack Start (React, web)
|
|
966
|
+
- [`examples/sveltekit/`](./examples/sveltekit/) - SvelteKit (Svelte, web)
|
|
967
|
+
- [`examples/expo/`](./examples/expo/) - Expo (React Native, mobile)
|
|
787
968
|
|
|
788
|
-
**
|
|
969
|
+
**Web features demonstrated:**
|
|
789
970
|
- Offline-first with SQLite persistence (sql.js + OPFS)
|
|
790
971
|
- Rich text editing with TipTap + Yjs collaboration
|
|
791
972
|
- PWA with custom service worker
|
|
792
973
|
- Real-time sync across devices
|
|
793
974
|
- Search with client-side text extraction (`prose.extract()`)
|
|
794
975
|
|
|
976
|
+
**Mobile features demonstrated (Expo):**
|
|
977
|
+
- Native SQLite persistence (op-sqlite)
|
|
978
|
+
- Plain TextInput prose binding via `useProseField` hook
|
|
979
|
+
- Crypto polyfills for React Native
|
|
980
|
+
|
|
795
981
|
## Development
|
|
796
982
|
|
|
797
983
|
```bash
|
|
798
|
-
bun run build # Build with
|
|
984
|
+
bun run build # Build with tsdown (includes ESLint + TypeScript checking)
|
|
799
985
|
bun run dev # Watch mode
|
|
800
986
|
bun run clean # Remove build artifacts
|
|
801
987
|
```
|