@trestleinc/replicate 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +201 -0
- package/LICENSE.package +201 -0
- package/README.md +871 -0
- package/dist/client/collection.d.ts +94 -0
- package/dist/client/index.d.ts +18 -0
- package/dist/client/logger.d.ts +3 -0
- package/dist/client/storage.d.ts +143 -0
- package/dist/component/_generated/api.js +5 -0
- package/dist/component/_generated/server.js +9 -0
- package/dist/component/convex.config.d.ts +2 -0
- package/dist/component/convex.config.js +3 -0
- package/dist/component/public.d.ts +99 -0
- package/dist/component/public.js +135 -0
- package/dist/component/schema.d.ts +22 -0
- package/dist/component/schema.js +22 -0
- package/dist/index.js +375 -0
- package/dist/server/index.d.ts +17 -0
- package/dist/server/replication.d.ts +122 -0
- package/dist/server/schema.d.ts +73 -0
- package/dist/server/ssr.d.ts +79 -0
- package/dist/server.js +96 -0
- package/dist/ssr.js +19 -0
- package/package.json +108 -0
- package/src/client/collection.ts +550 -0
- package/src/client/index.ts +31 -0
- package/src/client/logger.ts +31 -0
- package/src/client/storage.ts +206 -0
- package/src/component/_generated/api.d.ts +95 -0
- package/src/component/_generated/api.js +23 -0
- package/src/component/_generated/dataModel.d.ts +60 -0
- package/src/component/_generated/server.d.ts +149 -0
- package/src/component/_generated/server.js +90 -0
- package/src/component/convex.config.ts +3 -0
- package/src/component/public.ts +212 -0
- package/src/component/schema.ts +16 -0
- package/src/server/index.ts +26 -0
- package/src/server/replication.ts +244 -0
- package/src/server/schema.ts +97 -0
- package/src/server/ssr.ts +106 -0
package/dist/index.js
ADDED
|
@@ -0,0 +1,375 @@
|
|
|
1
|
+
import { componentsGeneric } from "convex/server";
|
|
2
|
+
import { NonRetriableError, startOfflineExecutor } from "@tanstack/offline-transactions";
|
|
3
|
+
import { getLogger } from "@logtape/logtape";
|
|
4
|
+
import * as __WEBPACK_EXTERNAL_MODULE_yjs__ from "yjs";
|
|
5
|
+
componentsGeneric();
|
|
6
|
+
class ReplicateStorage {
|
|
7
|
+
component;
|
|
8
|
+
collectionName;
|
|
9
|
+
constructor(component, collectionName){
|
|
10
|
+
this.component = component;
|
|
11
|
+
this.collectionName = collectionName;
|
|
12
|
+
}
|
|
13
|
+
async insertDocument(ctx, documentId, crdtBytes, version) {
|
|
14
|
+
return ctx.runMutation(this.component.public.insertDocument, {
|
|
15
|
+
collectionName: this.collectionName,
|
|
16
|
+
documentId,
|
|
17
|
+
crdtBytes,
|
|
18
|
+
version
|
|
19
|
+
});
|
|
20
|
+
}
|
|
21
|
+
async updateDocument(ctx, documentId, crdtBytes, version) {
|
|
22
|
+
return ctx.runMutation(this.component.public.updateDocument, {
|
|
23
|
+
collectionName: this.collectionName,
|
|
24
|
+
documentId,
|
|
25
|
+
crdtBytes,
|
|
26
|
+
version
|
|
27
|
+
});
|
|
28
|
+
}
|
|
29
|
+
async deleteDocument(ctx, documentId, crdtBytes, version) {
|
|
30
|
+
return ctx.runMutation(this.component.public.deleteDocument, {
|
|
31
|
+
collectionName: this.collectionName,
|
|
32
|
+
documentId,
|
|
33
|
+
crdtBytes,
|
|
34
|
+
version
|
|
35
|
+
});
|
|
36
|
+
}
|
|
37
|
+
async stream(ctx, checkpoint, limit) {
|
|
38
|
+
return ctx.runQuery(this.component.public.stream, {
|
|
39
|
+
collectionName: this.collectionName,
|
|
40
|
+
checkpoint,
|
|
41
|
+
limit
|
|
42
|
+
});
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
function logger_getLogger(category) {
|
|
46
|
+
return getLogger([
|
|
47
|
+
'convex-replicate',
|
|
48
|
+
...category
|
|
49
|
+
]);
|
|
50
|
+
}
|
|
51
|
+
const logger = logger_getLogger([
|
|
52
|
+
'convex-replicate',
|
|
53
|
+
'collection'
|
|
54
|
+
]);
|
|
55
|
+
function convexCollectionOptions({ getKey, initialData, convexClient, api, collectionName }) {
|
|
56
|
+
const ydoc = new __WEBPACK_EXTERNAL_MODULE_yjs__.Doc({
|
|
57
|
+
guid: collectionName
|
|
58
|
+
});
|
|
59
|
+
const ymap = ydoc.getMap(collectionName);
|
|
60
|
+
let pendingUpdate = null;
|
|
61
|
+
ydoc.on('update', (update, origin)=>{
|
|
62
|
+
pendingUpdate = update;
|
|
63
|
+
logger.debug('Yjs update event fired', {
|
|
64
|
+
collectionName,
|
|
65
|
+
updateSize: update.length,
|
|
66
|
+
origin
|
|
67
|
+
});
|
|
68
|
+
});
|
|
69
|
+
return {
|
|
70
|
+
id: collectionName,
|
|
71
|
+
getKey,
|
|
72
|
+
_convexClient: convexClient,
|
|
73
|
+
_collectionName: collectionName,
|
|
74
|
+
onInsert: async ({ transaction })=>{
|
|
75
|
+
logger.debug('onInsert handler called', {
|
|
76
|
+
collectionName,
|
|
77
|
+
mutationCount: transaction.mutations.length
|
|
78
|
+
});
|
|
79
|
+
try {
|
|
80
|
+
ydoc.transact(()=>{
|
|
81
|
+
transaction.mutations.forEach((mut)=>{
|
|
82
|
+
const itemYMap = new __WEBPACK_EXTERNAL_MODULE_yjs__.Map();
|
|
83
|
+
Object.entries(mut.modified).forEach(([k, v])=>{
|
|
84
|
+
itemYMap.set(k, v);
|
|
85
|
+
});
|
|
86
|
+
ymap.set(String(mut.key), itemYMap);
|
|
87
|
+
});
|
|
88
|
+
}, 'insert');
|
|
89
|
+
if (pendingUpdate) {
|
|
90
|
+
logger.debug('Sending insert delta to Convex', {
|
|
91
|
+
collectionName,
|
|
92
|
+
documentId: String(transaction.mutations[0].key),
|
|
93
|
+
deltaSize: pendingUpdate.length
|
|
94
|
+
});
|
|
95
|
+
await convexClient.mutation(api.insertDocument, {
|
|
96
|
+
collectionName,
|
|
97
|
+
documentId: String(transaction.mutations[0].key),
|
|
98
|
+
crdtBytes: pendingUpdate.buffer,
|
|
99
|
+
materializedDoc: transaction.mutations[0].modified,
|
|
100
|
+
version: Date.now()
|
|
101
|
+
});
|
|
102
|
+
pendingUpdate = null;
|
|
103
|
+
logger.info('Insert persisted to Convex', {
|
|
104
|
+
collectionName,
|
|
105
|
+
documentId: String(transaction.mutations[0].key)
|
|
106
|
+
});
|
|
107
|
+
}
|
|
108
|
+
} catch (error) {
|
|
109
|
+
logger.error('Insert failed', {
|
|
110
|
+
collectionName,
|
|
111
|
+
error: error?.message,
|
|
112
|
+
status: error?.status
|
|
113
|
+
});
|
|
114
|
+
if (error?.status === 401 || error?.status === 403) throw new NonRetriableError('Authentication failed');
|
|
115
|
+
if (error?.status === 422) throw new NonRetriableError('Validation error');
|
|
116
|
+
throw error;
|
|
117
|
+
}
|
|
118
|
+
},
|
|
119
|
+
onUpdate: async ({ transaction })=>{
|
|
120
|
+
logger.debug('onUpdate handler called', {
|
|
121
|
+
collectionName,
|
|
122
|
+
mutationCount: transaction.mutations.length
|
|
123
|
+
});
|
|
124
|
+
try {
|
|
125
|
+
ydoc.transact(()=>{
|
|
126
|
+
transaction.mutations.forEach((mut)=>{
|
|
127
|
+
const itemYMap = ymap.get(String(mut.key));
|
|
128
|
+
if (itemYMap) Object.entries(mut.modified || {}).forEach(([k, v])=>{
|
|
129
|
+
itemYMap.set(k, v);
|
|
130
|
+
});
|
|
131
|
+
else {
|
|
132
|
+
const newYMap = new __WEBPACK_EXTERNAL_MODULE_yjs__.Map();
|
|
133
|
+
Object.entries(mut.modified).forEach(([k, v])=>{
|
|
134
|
+
newYMap.set(k, v);
|
|
135
|
+
});
|
|
136
|
+
ymap.set(String(mut.key), newYMap);
|
|
137
|
+
}
|
|
138
|
+
});
|
|
139
|
+
}, 'update');
|
|
140
|
+
if (pendingUpdate) {
|
|
141
|
+
logger.debug('Sending update delta to Convex', {
|
|
142
|
+
collectionName,
|
|
143
|
+
documentId: String(transaction.mutations[0].key),
|
|
144
|
+
deltaSize: pendingUpdate.length
|
|
145
|
+
});
|
|
146
|
+
await convexClient.mutation(api.updateDocument, {
|
|
147
|
+
collectionName,
|
|
148
|
+
documentId: String(transaction.mutations[0].key),
|
|
149
|
+
crdtBytes: pendingUpdate.buffer,
|
|
150
|
+
materializedDoc: transaction.mutations[0].modified,
|
|
151
|
+
version: Date.now()
|
|
152
|
+
});
|
|
153
|
+
pendingUpdate = null;
|
|
154
|
+
logger.info('Update persisted to Convex', {
|
|
155
|
+
collectionName,
|
|
156
|
+
documentId: String(transaction.mutations[0].key)
|
|
157
|
+
});
|
|
158
|
+
}
|
|
159
|
+
} catch (error) {
|
|
160
|
+
logger.error('Update failed', {
|
|
161
|
+
collectionName,
|
|
162
|
+
error: error?.message,
|
|
163
|
+
status: error?.status
|
|
164
|
+
});
|
|
165
|
+
if (error?.status === 401 || error?.status === 403) throw new NonRetriableError('Authentication failed');
|
|
166
|
+
if (error?.status === 422) throw new NonRetriableError('Validation error');
|
|
167
|
+
throw error;
|
|
168
|
+
}
|
|
169
|
+
},
|
|
170
|
+
onDelete: async ({ transaction })=>{
|
|
171
|
+
logger.debug('onDelete handler called', {
|
|
172
|
+
collectionName,
|
|
173
|
+
mutationCount: transaction.mutations.length
|
|
174
|
+
});
|
|
175
|
+
try {
|
|
176
|
+
ydoc.transact(()=>{
|
|
177
|
+
transaction.mutations.forEach((mut)=>{
|
|
178
|
+
ymap.delete(String(mut.key));
|
|
179
|
+
});
|
|
180
|
+
}, 'delete');
|
|
181
|
+
if (pendingUpdate) {
|
|
182
|
+
logger.debug('Sending delete delta to Convex', {
|
|
183
|
+
collectionName,
|
|
184
|
+
documentId: String(transaction.mutations[0].key),
|
|
185
|
+
deltaSize: pendingUpdate.length
|
|
186
|
+
});
|
|
187
|
+
await convexClient.mutation(api.deleteDocument, {
|
|
188
|
+
collectionName,
|
|
189
|
+
documentId: String(transaction.mutations[0].key),
|
|
190
|
+
crdtBytes: pendingUpdate.buffer,
|
|
191
|
+
version: Date.now()
|
|
192
|
+
});
|
|
193
|
+
pendingUpdate = null;
|
|
194
|
+
logger.info('Delete persisted to Convex', {
|
|
195
|
+
collectionName,
|
|
196
|
+
documentId: String(transaction.mutations[0].key)
|
|
197
|
+
});
|
|
198
|
+
}
|
|
199
|
+
} catch (error) {
|
|
200
|
+
logger.error('Delete failed', {
|
|
201
|
+
collectionName,
|
|
202
|
+
error: error?.message,
|
|
203
|
+
status: error?.status
|
|
204
|
+
});
|
|
205
|
+
if (error?.status === 401 || error?.status === 403) throw new NonRetriableError('Authentication failed');
|
|
206
|
+
if (error?.status === 422) throw new NonRetriableError('Validation error');
|
|
207
|
+
throw error;
|
|
208
|
+
}
|
|
209
|
+
},
|
|
210
|
+
sync: {
|
|
211
|
+
sync: (params)=>{
|
|
212
|
+
const { begin, write, commit, markReady } = params;
|
|
213
|
+
if (initialData && initialData.length > 0) {
|
|
214
|
+
ydoc.transact(()=>{
|
|
215
|
+
for (const item of initialData){
|
|
216
|
+
const key = getKey(item);
|
|
217
|
+
const itemYMap = new __WEBPACK_EXTERNAL_MODULE_yjs__.Map();
|
|
218
|
+
Object.entries(item).forEach(([k, v])=>{
|
|
219
|
+
itemYMap.set(k, v);
|
|
220
|
+
});
|
|
221
|
+
ymap.set(String(key), itemYMap);
|
|
222
|
+
}
|
|
223
|
+
}, 'ssr-init');
|
|
224
|
+
begin();
|
|
225
|
+
for (const item of initialData)write({
|
|
226
|
+
type: 'insert',
|
|
227
|
+
value: item
|
|
228
|
+
});
|
|
229
|
+
commit();
|
|
230
|
+
logger.debug('Initialized with SSR data', {
|
|
231
|
+
collectionName,
|
|
232
|
+
count: initialData.length
|
|
233
|
+
});
|
|
234
|
+
}
|
|
235
|
+
logger.debug("Setting up Convex subscription", {
|
|
236
|
+
collectionName
|
|
237
|
+
});
|
|
238
|
+
let previousItems = new Map();
|
|
239
|
+
const subscription = convexClient.onUpdate(api.stream, {}, async (items)=>{
|
|
240
|
+
try {
|
|
241
|
+
logger.debug("Subscription update received", {
|
|
242
|
+
collectionName,
|
|
243
|
+
itemCount: items.length
|
|
244
|
+
});
|
|
245
|
+
const currentItems = new Map();
|
|
246
|
+
for (const item of items){
|
|
247
|
+
const key = getKey(item);
|
|
248
|
+
currentItems.set(key, item);
|
|
249
|
+
}
|
|
250
|
+
const deletedItems = [];
|
|
251
|
+
for (const [prevId, prevItem] of previousItems)if (!currentItems.has(prevId)) deletedItems.push(prevItem);
|
|
252
|
+
if (deletedItems.length > 0) logger.info('Detected remote hard deletes', {
|
|
253
|
+
collectionName,
|
|
254
|
+
deletedCount: deletedItems.length,
|
|
255
|
+
deletedIds: deletedItems.map((item)=>getKey(item))
|
|
256
|
+
});
|
|
257
|
+
begin();
|
|
258
|
+
for (const deletedItem of deletedItems){
|
|
259
|
+
const deletedId = getKey(deletedItem);
|
|
260
|
+
ydoc.transact(()=>{
|
|
261
|
+
ymap.delete(String(deletedId));
|
|
262
|
+
}, 'remote-delete');
|
|
263
|
+
write({
|
|
264
|
+
type: 'delete',
|
|
265
|
+
value: deletedItem
|
|
266
|
+
});
|
|
267
|
+
}
|
|
268
|
+
ydoc.transact(()=>{
|
|
269
|
+
for (const item of items){
|
|
270
|
+
const key = getKey(item);
|
|
271
|
+
const itemYMap = new __WEBPACK_EXTERNAL_MODULE_yjs__.Map();
|
|
272
|
+
Object.entries(item).forEach(([k, v])=>{
|
|
273
|
+
itemYMap.set(k, v);
|
|
274
|
+
});
|
|
275
|
+
ymap.set(String(key), itemYMap);
|
|
276
|
+
}
|
|
277
|
+
}, "subscription-sync");
|
|
278
|
+
for (const item of items){
|
|
279
|
+
const key = getKey(item);
|
|
280
|
+
params.collection.has(key) ? write({
|
|
281
|
+
type: 'update',
|
|
282
|
+
value: item
|
|
283
|
+
}) : write({
|
|
284
|
+
type: 'insert',
|
|
285
|
+
value: item
|
|
286
|
+
});
|
|
287
|
+
}
|
|
288
|
+
commit();
|
|
289
|
+
previousItems = currentItems;
|
|
290
|
+
logger.debug('Successfully synced items to collection', {
|
|
291
|
+
count: items.length,
|
|
292
|
+
deletedCount: deletedItems.length
|
|
293
|
+
});
|
|
294
|
+
} catch (error) {
|
|
295
|
+
logger.error("Failed to sync items from subscription", {
|
|
296
|
+
error: error.message,
|
|
297
|
+
errorName: error.name,
|
|
298
|
+
stack: error?.stack,
|
|
299
|
+
collectionName,
|
|
300
|
+
itemCount: items.length
|
|
301
|
+
});
|
|
302
|
+
throw error;
|
|
303
|
+
}
|
|
304
|
+
});
|
|
305
|
+
markReady();
|
|
306
|
+
return ()=>{
|
|
307
|
+
logger.debug("Cleaning up Convex subscription", {
|
|
308
|
+
collectionName
|
|
309
|
+
});
|
|
310
|
+
subscription();
|
|
311
|
+
};
|
|
312
|
+
}
|
|
313
|
+
}
|
|
314
|
+
};
|
|
315
|
+
}
|
|
316
|
+
function createConvexCollection(rawCollection) {
|
|
317
|
+
const config = rawCollection.config;
|
|
318
|
+
const convexClient = config._convexClient;
|
|
319
|
+
const collectionName = config._collectionName;
|
|
320
|
+
if (!convexClient || !collectionName) throw new Error("createConvexCollection requires a collection created with convexCollectionOptions. Make sure you pass convexClient and collectionName to convexCollectionOptions.");
|
|
321
|
+
logger.info('Creating Convex collection with offline support', {
|
|
322
|
+
collectionName
|
|
323
|
+
});
|
|
324
|
+
const offline = startOfflineExecutor({
|
|
325
|
+
collections: {
|
|
326
|
+
[collectionName]: rawCollection
|
|
327
|
+
},
|
|
328
|
+
mutationFns: {},
|
|
329
|
+
beforeRetry: (transactions)=>{
|
|
330
|
+
const cutoff = Date.now() - 86400000;
|
|
331
|
+
const filtered = transactions.filter((tx)=>{
|
|
332
|
+
const isRecent = tx.createdAt.getTime() > cutoff;
|
|
333
|
+
const notExhausted = tx.retryCount < 10;
|
|
334
|
+
return isRecent && notExhausted;
|
|
335
|
+
});
|
|
336
|
+
if (filtered.length < transactions.length) logger.warn('Filtered stale transactions', {
|
|
337
|
+
collectionName,
|
|
338
|
+
before: transactions.length,
|
|
339
|
+
after: filtered.length
|
|
340
|
+
});
|
|
341
|
+
return filtered;
|
|
342
|
+
},
|
|
343
|
+
onLeadershipChange: (isLeader)=>{
|
|
344
|
+
logger.info(isLeader ? 'Offline mode active' : 'Online-only mode', {
|
|
345
|
+
collectionName
|
|
346
|
+
});
|
|
347
|
+
},
|
|
348
|
+
onStorageFailure: (diagnostic)=>{
|
|
349
|
+
logger.warn('Storage failed - online-only mode', {
|
|
350
|
+
collectionName,
|
|
351
|
+
code: diagnostic.code,
|
|
352
|
+
message: diagnostic.message
|
|
353
|
+
});
|
|
354
|
+
}
|
|
355
|
+
});
|
|
356
|
+
if (convexClient.connectionState) {
|
|
357
|
+
const connectionState = convexClient.connectionState();
|
|
358
|
+
logger.debug('Initial connection state', {
|
|
359
|
+
collectionName,
|
|
360
|
+
isConnected: connectionState.isWebSocketConnected
|
|
361
|
+
});
|
|
362
|
+
}
|
|
363
|
+
if ('undefined' != typeof window) window.addEventListener('online', ()=>{
|
|
364
|
+
logger.info('Network online - notifying offline executor', {
|
|
365
|
+
collectionName
|
|
366
|
+
});
|
|
367
|
+
offline.notifyOnline();
|
|
368
|
+
});
|
|
369
|
+
logger.info('Offline support initialized', {
|
|
370
|
+
collectionName,
|
|
371
|
+
mode: offline.mode
|
|
372
|
+
});
|
|
373
|
+
return rawCollection;
|
|
374
|
+
}
|
|
375
|
+
export { NonRetriableError, ReplicateStorage, __WEBPACK_EXTERNAL_MODULE_yjs__ as Y, convexCollectionOptions, createConvexCollection };
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Server-side utilities for Convex backend.
|
|
3
|
+
* Import this in your Convex functions (convex/*.ts files).
|
|
4
|
+
*
|
|
5
|
+
* @example
|
|
6
|
+
* ```typescript
|
|
7
|
+
* // convex/tasks.ts
|
|
8
|
+
* import {
|
|
9
|
+
* insertDocumentHelper,
|
|
10
|
+
* updateDocumentHelper,
|
|
11
|
+
* deleteDocumentHelper,
|
|
12
|
+
* streamHelper,
|
|
13
|
+
* } from '@trestleinc/replicate/server';
|
|
14
|
+
* ```
|
|
15
|
+
*/
|
|
16
|
+
export { insertDocumentHelper, updateDocumentHelper, deleteDocumentHelper, streamHelper, } from './replication.js';
|
|
17
|
+
export { replicatedTable, type ReplicationFields } from './schema.js';
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
import type { GenericDataModel } from 'convex/server';
|
|
2
|
+
/**
|
|
3
|
+
* Insert a document into both the CRDT component and the main application table.
|
|
4
|
+
*
|
|
5
|
+
* DUAL-STORAGE ARCHITECTURE:
|
|
6
|
+
* This helper implements a dual-storage pattern where documents are stored in two places:
|
|
7
|
+
*
|
|
8
|
+
* 1. Component Storage (CRDT Layer):
|
|
9
|
+
* - Stores CRDT bytes (from Yjs) for offline-first conflict resolution
|
|
10
|
+
* - Handles concurrent updates with automatic merging
|
|
11
|
+
* - Provides the source of truth for offline changes
|
|
12
|
+
*
|
|
13
|
+
* 2. Main Application Table:
|
|
14
|
+
* - Stores materialized documents for efficient querying
|
|
15
|
+
* - Used by server-side Convex functions that need to query/join data
|
|
16
|
+
* - Optimized for reactive subscriptions and complex queries
|
|
17
|
+
*
|
|
18
|
+
* WHY BOTH?
|
|
19
|
+
* - Component: Handles conflict resolution and offline replication (CRDT bytes)
|
|
20
|
+
* - Main table: Enables efficient server-side queries (materialized docs)
|
|
21
|
+
* - Similar to event sourcing: component = event log, main table = read model
|
|
22
|
+
*
|
|
23
|
+
* @param ctx - Convex mutation context
|
|
24
|
+
* @param components - Generated components from Convex
|
|
25
|
+
* @param tableName - Name of the main application table
|
|
26
|
+
* @param args - Document data with id, crdtBytes, materializedDoc, and version
|
|
27
|
+
* @returns Success indicator
|
|
28
|
+
*/
|
|
29
|
+
export declare function insertDocumentHelper<_DataModel extends GenericDataModel>(ctx: unknown, components: unknown, tableName: string, args: {
|
|
30
|
+
id: string;
|
|
31
|
+
crdtBytes: ArrayBuffer;
|
|
32
|
+
materializedDoc: unknown;
|
|
33
|
+
version: number;
|
|
34
|
+
}): Promise<{
|
|
35
|
+
success: boolean;
|
|
36
|
+
metadata: {
|
|
37
|
+
documentId: string;
|
|
38
|
+
timestamp: number;
|
|
39
|
+
version: number;
|
|
40
|
+
collectionName: string;
|
|
41
|
+
};
|
|
42
|
+
}>;
|
|
43
|
+
/**
|
|
44
|
+
* Update a document in both the CRDT component and the main application table.
|
|
45
|
+
*
|
|
46
|
+
* @param ctx - Convex mutation context
|
|
47
|
+
* @param components - Generated components from Convex
|
|
48
|
+
* @param tableName - Name of the main application table
|
|
49
|
+
* @param args - Document data with id, crdtBytes, materializedDoc, and version
|
|
50
|
+
* @returns Success indicator
|
|
51
|
+
*/
|
|
52
|
+
export declare function updateDocumentHelper<_DataModel extends GenericDataModel>(ctx: unknown, components: unknown, tableName: string, args: {
|
|
53
|
+
id: string;
|
|
54
|
+
crdtBytes: ArrayBuffer;
|
|
55
|
+
materializedDoc: unknown;
|
|
56
|
+
version: number;
|
|
57
|
+
}): Promise<{
|
|
58
|
+
success: boolean;
|
|
59
|
+
metadata: {
|
|
60
|
+
documentId: string;
|
|
61
|
+
timestamp: number;
|
|
62
|
+
version: number;
|
|
63
|
+
collectionName: string;
|
|
64
|
+
};
|
|
65
|
+
}>;
|
|
66
|
+
/**
|
|
67
|
+
* HARD delete a document from main table, APPEND deletion delta to component.
|
|
68
|
+
*
|
|
69
|
+
* NEW BEHAVIOR (v0.3.0):
|
|
70
|
+
* - Appends deletion delta to component event log (preserves history)
|
|
71
|
+
* - Physically removes document from main table (hard delete)
|
|
72
|
+
* - CRDT history preserved for future recovery features
|
|
73
|
+
*
|
|
74
|
+
* @param ctx - Convex mutation context
|
|
75
|
+
* @param components - Generated components from Convex
|
|
76
|
+
* @param tableName - Name of the main application table
|
|
77
|
+
* @param args - Document data with id, crdtBytes (deletion delta), and version
|
|
78
|
+
* @returns Success indicator with metadata
|
|
79
|
+
*/
|
|
80
|
+
export declare function deleteDocumentHelper<_DataModel extends GenericDataModel>(ctx: unknown, components: unknown, tableName: string, args: {
|
|
81
|
+
id: string;
|
|
82
|
+
crdtBytes: ArrayBuffer;
|
|
83
|
+
version: number;
|
|
84
|
+
}): Promise<{
|
|
85
|
+
success: boolean;
|
|
86
|
+
metadata: {
|
|
87
|
+
documentId: string;
|
|
88
|
+
timestamp: number;
|
|
89
|
+
version: number;
|
|
90
|
+
collectionName: string;
|
|
91
|
+
};
|
|
92
|
+
}>;
|
|
93
|
+
/**
|
|
94
|
+
* Stream document changes from the CRDT component storage.
|
|
95
|
+
*
|
|
96
|
+
* This reads CRDT bytes from the component (not the main table) to enable
|
|
97
|
+
* true Y.applyUpdate() conflict resolution on the client.
|
|
98
|
+
* Can be used for both polling (awaitReplication) and subscriptions (live updates).
|
|
99
|
+
*
|
|
100
|
+
* @param ctx - Convex query context
|
|
101
|
+
* @param components - Generated components from Convex
|
|
102
|
+
* @param tableName - Name of the collection
|
|
103
|
+
* @param args - Checkpoint and limit for pagination
|
|
104
|
+
* @returns Array of changes with CRDT bytes
|
|
105
|
+
*/
|
|
106
|
+
export declare function streamHelper<_DataModel extends GenericDataModel>(ctx: unknown, components: unknown, tableName: string, args: {
|
|
107
|
+
checkpoint: {
|
|
108
|
+
lastModified: number;
|
|
109
|
+
};
|
|
110
|
+
limit?: number;
|
|
111
|
+
}): Promise<{
|
|
112
|
+
changes: Array<{
|
|
113
|
+
documentId: string;
|
|
114
|
+
crdtBytes: ArrayBuffer;
|
|
115
|
+
version: number;
|
|
116
|
+
timestamp: number;
|
|
117
|
+
}>;
|
|
118
|
+
checkpoint: {
|
|
119
|
+
lastModified: number;
|
|
120
|
+
};
|
|
121
|
+
hasMore: boolean;
|
|
122
|
+
}>;
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Schema utilities for defining replicated tables.
|
|
3
|
+
* Automatically adds replication metadata fields so users don't have to.
|
|
4
|
+
*
|
|
5
|
+
* @example
|
|
6
|
+
* ```typescript
|
|
7
|
+
* // convex/schema.ts
|
|
8
|
+
* import { defineSchema } from 'convex/server';
|
|
9
|
+
* import { v } from 'convex/values';
|
|
10
|
+
* import { replicatedTable } from '@trestleinc/replicate/server';
|
|
11
|
+
*
|
|
12
|
+
* export default defineSchema({
|
|
13
|
+
* tasks: replicatedTable(
|
|
14
|
+
* {
|
|
15
|
+
* id: v.string(),
|
|
16
|
+
* text: v.string(),
|
|
17
|
+
* isCompleted: v.boolean(),
|
|
18
|
+
* },
|
|
19
|
+
* (table) => table
|
|
20
|
+
* .index('by_id', ['id'])
|
|
21
|
+
* .index('by_timestamp', ['timestamp'])
|
|
22
|
+
* ),
|
|
23
|
+
* });
|
|
24
|
+
* ```
|
|
25
|
+
*/
|
|
26
|
+
/**
|
|
27
|
+
* Internal replication metadata fields added to every replicated table.
|
|
28
|
+
* These are managed automatically by the replication layer.
|
|
29
|
+
*/
|
|
30
|
+
export type ReplicationFields = {
|
|
31
|
+
/** Version number for conflict resolution */
|
|
32
|
+
version: number;
|
|
33
|
+
/** Last modification timestamp (Unix ms) */
|
|
34
|
+
timestamp: number;
|
|
35
|
+
};
|
|
36
|
+
/**
|
|
37
|
+
* Wraps a table definition to automatically add replication metadata fields.
|
|
38
|
+
*
|
|
39
|
+
* Users define their business logic fields, and we inject:
|
|
40
|
+
* - `version` - For conflict resolution and CRDT versioning
|
|
41
|
+
* - `timestamp` - For incremental sync and change tracking
|
|
42
|
+
*
|
|
43
|
+
* Enables:
|
|
44
|
+
* - Dual-storage architecture (CRDT component + main table)
|
|
45
|
+
* - Conflict-free replication across clients
|
|
46
|
+
* - Hard delete support with CRDT history preservation
|
|
47
|
+
* - Event sourcing via component storage
|
|
48
|
+
*
|
|
49
|
+
* @param userFields - User's business logic fields (id, text, etc.)
|
|
50
|
+
* @param applyIndexes - Optional callback to add indexes to the table
|
|
51
|
+
* @returns TableDefinition with replication fields injected
|
|
52
|
+
*
|
|
53
|
+
* @example
|
|
54
|
+
* ```typescript
|
|
55
|
+
* // Simple table with hard delete support
|
|
56
|
+
* tasks: replicatedTable({
|
|
57
|
+
* id: v.string(),
|
|
58
|
+
* text: v.string(),
|
|
59
|
+
* })
|
|
60
|
+
*
|
|
61
|
+
* // With indexes
|
|
62
|
+
* tasks: replicatedTable(
|
|
63
|
+
* {
|
|
64
|
+
* id: v.string(),
|
|
65
|
+
* text: v.string(),
|
|
66
|
+
* },
|
|
67
|
+
* (table) => table
|
|
68
|
+
* .index('by_id', ['id'])
|
|
69
|
+
* .index('by_timestamp', ['timestamp'])
|
|
70
|
+
* )
|
|
71
|
+
* ```
|
|
72
|
+
*/
|
|
73
|
+
export declare function replicatedTable(userFields: Record<string, any>, applyIndexes?: (table: any) => any): any;
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Server-Side Rendering (SSR) Utilities
|
|
3
|
+
*
|
|
4
|
+
* This module provides utilities for loading collection data during
|
|
5
|
+
* server-side rendering. Use `loadCollection` with an explicit config
|
|
6
|
+
* object for clarity and type safety.
|
|
7
|
+
*
|
|
8
|
+
* @module ssr
|
|
9
|
+
* @example
|
|
10
|
+
* ```typescript
|
|
11
|
+
* import { loadCollection } from '@convex-replicate/core/ssr';
|
|
12
|
+
* import { api } from '../convex/_generated/api';
|
|
13
|
+
*
|
|
14
|
+
* const tasks = await loadCollection<Task>(httpClient, {
|
|
15
|
+
* api: api.tasks,
|
|
16
|
+
* collection: 'tasks',
|
|
17
|
+
* limit: 100,
|
|
18
|
+
* });
|
|
19
|
+
* ```
|
|
20
|
+
*/
|
|
21
|
+
import type { ConvexHttpClient } from 'convex/browser';
|
|
22
|
+
import type { FunctionReference } from 'convex/server';
|
|
23
|
+
/**
|
|
24
|
+
* API module shape expected by loadCollection.
|
|
25
|
+
*
|
|
26
|
+
* This should match the generated API module for your collection
|
|
27
|
+
* (e.g., api.tasks, api.users, etc.)
|
|
28
|
+
*/
|
|
29
|
+
export type CollectionAPI = {
|
|
30
|
+
stream: FunctionReference<'query', 'public' | 'internal'>;
|
|
31
|
+
};
|
|
32
|
+
/**
|
|
33
|
+
* Configuration for loading collection data during SSR.
|
|
34
|
+
*/
|
|
35
|
+
export interface LoadCollectionConfig {
|
|
36
|
+
/** The API module for the collection (e.g., api.tasks) */
|
|
37
|
+
api: CollectionAPI;
|
|
38
|
+
/** The collection name (should match the API module name) */
|
|
39
|
+
collection: string;
|
|
40
|
+
/** Maximum number of items to load (default: 100) */
|
|
41
|
+
limit?: number;
|
|
42
|
+
}
|
|
43
|
+
/**
|
|
44
|
+
* Load collection data for server-side rendering.
|
|
45
|
+
*
|
|
46
|
+
* **IMPORTANT**: This function is currently limited because `stream` only returns
|
|
47
|
+
* CRDT bytes, not materialized documents. For most SSR use cases, it's recommended to
|
|
48
|
+
* create a separate query that reads from your main table instead.
|
|
49
|
+
*
|
|
50
|
+
* @deprecated Consider creating a dedicated SSR query instead. See example below.
|
|
51
|
+
*
|
|
52
|
+
* @param httpClient - Convex HTTP client for server-side queries
|
|
53
|
+
* @param config - Configuration object with api, collection, and options
|
|
54
|
+
* @returns Promise resolving to array of items from the collection
|
|
55
|
+
*
|
|
56
|
+
* @example
|
|
57
|
+
* **Recommended SSR Pattern:**
|
|
58
|
+
* ```typescript
|
|
59
|
+
* // convex/tasks.ts
|
|
60
|
+
* export const list = query({
|
|
61
|
+
* handler: async (ctx) => {
|
|
62
|
+
* return await ctx.db
|
|
63
|
+
* .query('tasks')
|
|
64
|
+
* .filter((q) => q.neq(q.field('deleted'), true))
|
|
65
|
+
* .collect();
|
|
66
|
+
* },
|
|
67
|
+
* });
|
|
68
|
+
*
|
|
69
|
+
* // In your route loader
|
|
70
|
+
* import { ConvexHttpClient } from 'convex/browser';
|
|
71
|
+
* import { api } from '../convex/_generated/api';
|
|
72
|
+
*
|
|
73
|
+
* const httpClient = new ConvexHttpClient(import.meta.env.VITE_CONVEX_URL);
|
|
74
|
+
* const tasks = await httpClient.query(api.tasks.list);
|
|
75
|
+
* ```
|
|
76
|
+
*/
|
|
77
|
+
export declare function loadCollection<TItem extends {
|
|
78
|
+
id: string;
|
|
79
|
+
}>(httpClient: ConvexHttpClient, config: LoadCollectionConfig): Promise<ReadonlyArray<TItem>>;
|