@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
|
@@ -0,0 +1,550 @@
|
|
|
1
|
+
import * as Y from 'yjs';
|
|
2
|
+
import {
|
|
3
|
+
startOfflineExecutor,
|
|
4
|
+
NonRetriableError,
|
|
5
|
+
type OfflineExecutor,
|
|
6
|
+
} from '@tanstack/offline-transactions';
|
|
7
|
+
import type { ConvexClient } from 'convex/browser';
|
|
8
|
+
import type { FunctionReference } from 'convex/server';
|
|
9
|
+
import type { CollectionConfig, Collection } from '@tanstack/db';
|
|
10
|
+
import { getLogger } from './logger.js';
|
|
11
|
+
|
|
12
|
+
const logger = getLogger(['convex-replicate', 'collection']);
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Configuration for convexCollectionOptions (Step 1)
|
|
16
|
+
* All params go here - they'll be used to create the collection config
|
|
17
|
+
*/
|
|
18
|
+
export interface ConvexCollectionOptionsConfig<T extends object> {
|
|
19
|
+
/** Function to extract unique key from items */
|
|
20
|
+
getKey: (item: T) => string | number;
|
|
21
|
+
|
|
22
|
+
/** Optional initial data to populate collection */
|
|
23
|
+
initialData?: ReadonlyArray<T>;
|
|
24
|
+
|
|
25
|
+
/** Convex client instance */
|
|
26
|
+
convexClient: ConvexClient;
|
|
27
|
+
|
|
28
|
+
/** Convex API functions for this collection */
|
|
29
|
+
api: {
|
|
30
|
+
stream: FunctionReference<'query'>; // For streaming data from main table (required)
|
|
31
|
+
insertDocument: FunctionReference<'mutation'>; // Insert handler (required)
|
|
32
|
+
updateDocument: FunctionReference<'mutation'>; // Update handler (required)
|
|
33
|
+
deleteDocument: FunctionReference<'mutation'>; // Delete handler (required)
|
|
34
|
+
};
|
|
35
|
+
|
|
36
|
+
/** Unique collection name */
|
|
37
|
+
collectionName: string;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* ConvexCollection is now just a standard TanStack DB Collection!
|
|
42
|
+
* No custom wrapper, no special methods - uses built-in transaction system.
|
|
43
|
+
*/
|
|
44
|
+
export type ConvexCollection<T extends object> = Collection<T>;
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Step 1: Create TanStack DB CollectionConfig with REAL mutation handlers.
|
|
48
|
+
*
|
|
49
|
+
* This implements the CORRECT pattern:
|
|
50
|
+
* - Uses onInsert/onUpdate/onDelete handlers (not custom wrapper)
|
|
51
|
+
* - Yjs Y.Doc with 'update' event for delta encoding
|
|
52
|
+
* - Stores Y.Map instances (not plain objects) for field-level CRDT
|
|
53
|
+
* - Uses ydoc.transact() to batch changes into single 'update' event
|
|
54
|
+
*
|
|
55
|
+
* @example
|
|
56
|
+
* ```typescript
|
|
57
|
+
* import { createCollection } from '@tanstack/react-db'
|
|
58
|
+
* import { convexCollectionOptions } from '@trestleinc/convex-replicate-core'
|
|
59
|
+
*
|
|
60
|
+
* const rawCollection = createCollection(
|
|
61
|
+
* convexCollectionOptions<Task>({
|
|
62
|
+
* convexClient,
|
|
63
|
+
* api: api.tasks,
|
|
64
|
+
* collectionName: 'tasks',
|
|
65
|
+
* getKey: (task) => task.id,
|
|
66
|
+
* initialData,
|
|
67
|
+
* })
|
|
68
|
+
* )
|
|
69
|
+
* ```
|
|
70
|
+
*/
|
|
71
|
+
export function convexCollectionOptions<T extends object>({
|
|
72
|
+
getKey,
|
|
73
|
+
initialData,
|
|
74
|
+
convexClient,
|
|
75
|
+
api,
|
|
76
|
+
collectionName,
|
|
77
|
+
}: ConvexCollectionOptionsConfig<T>): CollectionConfig<T> & {
|
|
78
|
+
_convexClient: ConvexClient;
|
|
79
|
+
_collectionName: string;
|
|
80
|
+
} {
|
|
81
|
+
// Initialize Yjs document for CRDT operations
|
|
82
|
+
const ydoc = new Y.Doc({ guid: collectionName });
|
|
83
|
+
const ymap = ydoc.getMap(collectionName);
|
|
84
|
+
|
|
85
|
+
// Track delta updates (NOT full state)
|
|
86
|
+
// This is the key to efficient bandwidth usage: < 1KB per change instead of 100KB+
|
|
87
|
+
let pendingUpdate: Uint8Array | null = null;
|
|
88
|
+
(ydoc as any).on('update', (update: Uint8Array, origin: any) => {
|
|
89
|
+
// `update` contains ONLY what changed (delta)
|
|
90
|
+
pendingUpdate = update;
|
|
91
|
+
logger.debug('Yjs update event fired', {
|
|
92
|
+
collectionName,
|
|
93
|
+
updateSize: update.length,
|
|
94
|
+
origin,
|
|
95
|
+
});
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
return {
|
|
99
|
+
id: collectionName,
|
|
100
|
+
getKey,
|
|
101
|
+
|
|
102
|
+
// Store for extraction by createConvexCollection
|
|
103
|
+
_convexClient: convexClient,
|
|
104
|
+
_collectionName: collectionName,
|
|
105
|
+
|
|
106
|
+
// REAL onInsert handler (called automatically by TanStack DB)
|
|
107
|
+
onInsert: async ({ transaction }: any) => {
|
|
108
|
+
logger.debug('onInsert handler called', {
|
|
109
|
+
collectionName,
|
|
110
|
+
mutationCount: transaction.mutations.length,
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
try {
|
|
114
|
+
// Update Yjs in transaction (batches multiple changes into ONE 'update' event)
|
|
115
|
+
ydoc.transact(() => {
|
|
116
|
+
transaction.mutations.forEach((mut: any) => {
|
|
117
|
+
// Store as Y.Map for field-level CRDT conflict resolution
|
|
118
|
+
const itemYMap = new Y.Map();
|
|
119
|
+
Object.entries(mut.modified as Record<string, unknown>).forEach(([k, v]) => {
|
|
120
|
+
itemYMap.set(k, v);
|
|
121
|
+
});
|
|
122
|
+
ymap.set(String(mut.key), itemYMap);
|
|
123
|
+
});
|
|
124
|
+
}, 'insert');
|
|
125
|
+
|
|
126
|
+
// Send DELTA to Convex (not full state)
|
|
127
|
+
if (pendingUpdate) {
|
|
128
|
+
logger.debug('Sending insert delta to Convex', {
|
|
129
|
+
collectionName,
|
|
130
|
+
documentId: String(transaction.mutations[0].key),
|
|
131
|
+
deltaSize: pendingUpdate.length,
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
await convexClient.mutation(api.insertDocument, {
|
|
135
|
+
collectionName,
|
|
136
|
+
documentId: String(transaction.mutations[0].key),
|
|
137
|
+
crdtBytes: pendingUpdate.buffer,
|
|
138
|
+
materializedDoc: transaction.mutations[0].modified,
|
|
139
|
+
version: Date.now(),
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
pendingUpdate = null;
|
|
143
|
+
logger.info('Insert persisted to Convex', {
|
|
144
|
+
collectionName,
|
|
145
|
+
documentId: String(transaction.mutations[0].key),
|
|
146
|
+
});
|
|
147
|
+
}
|
|
148
|
+
} catch (error: any) {
|
|
149
|
+
logger.error('Insert failed', {
|
|
150
|
+
collectionName,
|
|
151
|
+
error: error?.message,
|
|
152
|
+
status: error?.status,
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
// Classify errors for retry behavior
|
|
156
|
+
if (error?.status === 401 || error?.status === 403) {
|
|
157
|
+
throw new NonRetriableError('Authentication failed');
|
|
158
|
+
}
|
|
159
|
+
if (error?.status === 422) {
|
|
160
|
+
throw new NonRetriableError('Validation error');
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
// Network errors retry automatically
|
|
164
|
+
throw error;
|
|
165
|
+
}
|
|
166
|
+
},
|
|
167
|
+
|
|
168
|
+
// REAL onUpdate handler (called automatically by TanStack DB)
|
|
169
|
+
onUpdate: async ({ transaction }: any) => {
|
|
170
|
+
logger.debug('onUpdate handler called', {
|
|
171
|
+
collectionName,
|
|
172
|
+
mutationCount: transaction.mutations.length,
|
|
173
|
+
});
|
|
174
|
+
|
|
175
|
+
try {
|
|
176
|
+
// Update Yjs in transaction
|
|
177
|
+
ydoc.transact(() => {
|
|
178
|
+
transaction.mutations.forEach((mut: any) => {
|
|
179
|
+
const itemYMap = ymap.get(String(mut.key)) as Y.Map<any> | undefined;
|
|
180
|
+
if (itemYMap) {
|
|
181
|
+
// Update only changed fields (field-level CRDT)
|
|
182
|
+
Object.entries((mut.modified as Record<string, unknown>) || {}).forEach(([k, v]) => {
|
|
183
|
+
itemYMap.set(k, v);
|
|
184
|
+
});
|
|
185
|
+
} else {
|
|
186
|
+
// Create new Y.Map if doesn't exist (defensive)
|
|
187
|
+
const newYMap = new Y.Map();
|
|
188
|
+
Object.entries(mut.modified as Record<string, unknown>).forEach(([k, v]) => {
|
|
189
|
+
newYMap.set(k, v);
|
|
190
|
+
});
|
|
191
|
+
ymap.set(String(mut.key), newYMap);
|
|
192
|
+
}
|
|
193
|
+
});
|
|
194
|
+
}, 'update');
|
|
195
|
+
|
|
196
|
+
// Send delta to Convex
|
|
197
|
+
if (pendingUpdate) {
|
|
198
|
+
logger.debug('Sending update delta to Convex', {
|
|
199
|
+
collectionName,
|
|
200
|
+
documentId: String(transaction.mutations[0].key),
|
|
201
|
+
deltaSize: pendingUpdate.length,
|
|
202
|
+
});
|
|
203
|
+
|
|
204
|
+
await convexClient.mutation(api.updateDocument, {
|
|
205
|
+
collectionName,
|
|
206
|
+
documentId: String(transaction.mutations[0].key),
|
|
207
|
+
crdtBytes: pendingUpdate.buffer,
|
|
208
|
+
materializedDoc: transaction.mutations[0].modified,
|
|
209
|
+
version: Date.now(),
|
|
210
|
+
});
|
|
211
|
+
|
|
212
|
+
pendingUpdate = null;
|
|
213
|
+
logger.info('Update persisted to Convex', {
|
|
214
|
+
collectionName,
|
|
215
|
+
documentId: String(transaction.mutations[0].key),
|
|
216
|
+
});
|
|
217
|
+
}
|
|
218
|
+
} catch (error: any) {
|
|
219
|
+
logger.error('Update failed', {
|
|
220
|
+
collectionName,
|
|
221
|
+
error: error?.message,
|
|
222
|
+
status: error?.status,
|
|
223
|
+
});
|
|
224
|
+
|
|
225
|
+
// Classify errors
|
|
226
|
+
if (error?.status === 401 || error?.status === 403) {
|
|
227
|
+
throw new NonRetriableError('Authentication failed');
|
|
228
|
+
}
|
|
229
|
+
if (error?.status === 422) {
|
|
230
|
+
throw new NonRetriableError('Validation error');
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
throw error;
|
|
234
|
+
}
|
|
235
|
+
},
|
|
236
|
+
|
|
237
|
+
// onDelete handler (called when user does collection.delete())
|
|
238
|
+
onDelete: async ({ transaction }: any) => {
|
|
239
|
+
logger.debug('onDelete handler called', {
|
|
240
|
+
collectionName,
|
|
241
|
+
mutationCount: transaction.mutations.length,
|
|
242
|
+
});
|
|
243
|
+
|
|
244
|
+
try {
|
|
245
|
+
// Remove from Yjs Y.Map - creates deletion tombstone
|
|
246
|
+
ydoc.transact(() => {
|
|
247
|
+
transaction.mutations.forEach((mut: any) => {
|
|
248
|
+
ymap.delete(String(mut.key));
|
|
249
|
+
});
|
|
250
|
+
}, 'delete');
|
|
251
|
+
|
|
252
|
+
// Send deletion DELTA to Convex
|
|
253
|
+
if (pendingUpdate) {
|
|
254
|
+
logger.debug('Sending delete delta to Convex', {
|
|
255
|
+
collectionName,
|
|
256
|
+
documentId: String(transaction.mutations[0].key),
|
|
257
|
+
deltaSize: pendingUpdate.length,
|
|
258
|
+
});
|
|
259
|
+
|
|
260
|
+
await convexClient.mutation(api.deleteDocument, {
|
|
261
|
+
collectionName,
|
|
262
|
+
documentId: String(transaction.mutations[0].key),
|
|
263
|
+
crdtBytes: pendingUpdate.buffer,
|
|
264
|
+
version: Date.now(),
|
|
265
|
+
});
|
|
266
|
+
|
|
267
|
+
pendingUpdate = null;
|
|
268
|
+
logger.info('Delete persisted to Convex', {
|
|
269
|
+
collectionName,
|
|
270
|
+
documentId: String(transaction.mutations[0].key),
|
|
271
|
+
});
|
|
272
|
+
}
|
|
273
|
+
} catch (error: any) {
|
|
274
|
+
logger.error('Delete failed', {
|
|
275
|
+
collectionName,
|
|
276
|
+
error: error?.message,
|
|
277
|
+
status: error?.status,
|
|
278
|
+
});
|
|
279
|
+
|
|
280
|
+
if (error?.status === 401 || error?.status === 403) {
|
|
281
|
+
throw new NonRetriableError('Authentication failed');
|
|
282
|
+
}
|
|
283
|
+
if (error?.status === 422) {
|
|
284
|
+
throw new NonRetriableError('Validation error');
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
throw error;
|
|
288
|
+
}
|
|
289
|
+
},
|
|
290
|
+
|
|
291
|
+
// Sync function for pulling data from server
|
|
292
|
+
sync: {
|
|
293
|
+
sync: (params: any) => {
|
|
294
|
+
const { begin, write, commit, markReady } = params;
|
|
295
|
+
|
|
296
|
+
// Step 1: Write initial SSR data to BOTH Yjs AND TanStack DB
|
|
297
|
+
if (initialData && initialData.length > 0) {
|
|
298
|
+
// Sync to Yjs first (for CRDT state)
|
|
299
|
+
ydoc.transact(() => {
|
|
300
|
+
for (const item of initialData) {
|
|
301
|
+
const key = getKey(item);
|
|
302
|
+
const itemYMap = new Y.Map();
|
|
303
|
+
Object.entries(item as Record<string, unknown>).forEach(([k, v]) => {
|
|
304
|
+
itemYMap.set(k, v);
|
|
305
|
+
});
|
|
306
|
+
ymap.set(String(key), itemYMap);
|
|
307
|
+
}
|
|
308
|
+
}, 'ssr-init');
|
|
309
|
+
|
|
310
|
+
// Then sync to TanStack DB
|
|
311
|
+
begin();
|
|
312
|
+
for (const item of initialData) {
|
|
313
|
+
write({ type: 'insert', value: item });
|
|
314
|
+
}
|
|
315
|
+
commit();
|
|
316
|
+
logger.debug('Initialized with SSR data', {
|
|
317
|
+
collectionName,
|
|
318
|
+
count: initialData.length,
|
|
319
|
+
});
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
// Step 2: Subscribe to Convex real-time updates via main table
|
|
323
|
+
logger.debug('Setting up Convex subscription', { collectionName });
|
|
324
|
+
|
|
325
|
+
// Track previous items (full objects) to detect hard deletes
|
|
326
|
+
// We need full items because TanStack DB write() expects { type: 'delete', value: T }
|
|
327
|
+
let previousItems = new Map<string | number, T>();
|
|
328
|
+
|
|
329
|
+
const subscription = convexClient.onUpdate(api.stream, {}, async (items) => {
|
|
330
|
+
try {
|
|
331
|
+
logger.debug('Subscription update received', {
|
|
332
|
+
collectionName,
|
|
333
|
+
itemCount: items.length,
|
|
334
|
+
});
|
|
335
|
+
|
|
336
|
+
// Build map of current items
|
|
337
|
+
const currentItems = new Map<string | number, T>();
|
|
338
|
+
for (const item of items) {
|
|
339
|
+
const key = getKey(item as T);
|
|
340
|
+
currentItems.set(key, item as T);
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
// Detect hard deletes by finding items in previous but not in current
|
|
344
|
+
const deletedItems: T[] = [];
|
|
345
|
+
for (const [prevId, prevItem] of previousItems) {
|
|
346
|
+
if (!currentItems.has(prevId)) {
|
|
347
|
+
deletedItems.push(prevItem);
|
|
348
|
+
}
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
if (deletedItems.length > 0) {
|
|
352
|
+
logger.info('Detected remote hard deletes', {
|
|
353
|
+
collectionName,
|
|
354
|
+
deletedCount: deletedItems.length,
|
|
355
|
+
deletedIds: deletedItems.map((item) => getKey(item)),
|
|
356
|
+
});
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
begin();
|
|
360
|
+
|
|
361
|
+
// STEP 1: Handle deletions FIRST
|
|
362
|
+
for (const deletedItem of deletedItems) {
|
|
363
|
+
const deletedId = getKey(deletedItem);
|
|
364
|
+
|
|
365
|
+
// Remove from Yjs (requires string key)
|
|
366
|
+
ydoc.transact(() => {
|
|
367
|
+
ymap.delete(String(deletedId));
|
|
368
|
+
}, 'remote-delete');
|
|
369
|
+
|
|
370
|
+
// Remove from TanStack DB (requires full item as value)
|
|
371
|
+
write({ type: 'delete', value: deletedItem });
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
// STEP 2: Sync items to Yjs
|
|
375
|
+
ydoc.transact(() => {
|
|
376
|
+
for (const item of items) {
|
|
377
|
+
const key = getKey(item as T);
|
|
378
|
+
const itemYMap = new Y.Map();
|
|
379
|
+
Object.entries(item as Record<string, unknown>).forEach(([k, v]) => {
|
|
380
|
+
itemYMap.set(k, v);
|
|
381
|
+
});
|
|
382
|
+
ymap.set(String(key), itemYMap);
|
|
383
|
+
}
|
|
384
|
+
}, 'subscription-sync');
|
|
385
|
+
|
|
386
|
+
// STEP 3: Sync items to TanStack DB
|
|
387
|
+
for (const item of items) {
|
|
388
|
+
const key = getKey(item as T);
|
|
389
|
+
|
|
390
|
+
if ((params as any).collection.has(key)) {
|
|
391
|
+
write({ type: 'update', value: item as T });
|
|
392
|
+
} else {
|
|
393
|
+
write({ type: 'insert', value: item as T });
|
|
394
|
+
}
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
commit();
|
|
398
|
+
|
|
399
|
+
// Update tracking for next iteration
|
|
400
|
+
previousItems = currentItems;
|
|
401
|
+
|
|
402
|
+
logger.debug('Successfully synced items to collection', {
|
|
403
|
+
count: items.length,
|
|
404
|
+
deletedCount: deletedItems.length,
|
|
405
|
+
});
|
|
406
|
+
} catch (error: any) {
|
|
407
|
+
logger.error('Failed to sync items from subscription', {
|
|
408
|
+
error: error.message,
|
|
409
|
+
errorName: error.name,
|
|
410
|
+
stack: error?.stack,
|
|
411
|
+
collectionName,
|
|
412
|
+
itemCount: items.length,
|
|
413
|
+
});
|
|
414
|
+
throw error; // Re-throw to prevent silent failures
|
|
415
|
+
}
|
|
416
|
+
});
|
|
417
|
+
|
|
418
|
+
markReady();
|
|
419
|
+
|
|
420
|
+
// Return cleanup function
|
|
421
|
+
return () => {
|
|
422
|
+
logger.debug('Cleaning up Convex subscription', { collectionName });
|
|
423
|
+
subscription();
|
|
424
|
+
};
|
|
425
|
+
},
|
|
426
|
+
},
|
|
427
|
+
};
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
/**
|
|
431
|
+
* Step 2: Wrap collection with offline support.
|
|
432
|
+
*
|
|
433
|
+
* This implements the CORRECT pattern:
|
|
434
|
+
* - Wraps collection ONCE with startOfflineExecutor
|
|
435
|
+
* - Returns raw collection (NO CUSTOM WRAPPER)
|
|
436
|
+
* - Uses beforeRetry filter for stale transactions
|
|
437
|
+
* - Connects to Convex connection state for retry triggers
|
|
438
|
+
*
|
|
439
|
+
* Config is automatically extracted from the rawCollection!
|
|
440
|
+
*
|
|
441
|
+
* @example
|
|
442
|
+
* ```typescript
|
|
443
|
+
* import { createCollection } from '@tanstack/react-db'
|
|
444
|
+
* import { convexCollectionOptions, createConvexCollection } from '@trestleinc/convex-replicate-core'
|
|
445
|
+
*
|
|
446
|
+
* // Step 1: Create raw collection with ALL config
|
|
447
|
+
* const rawCollection = createCollection(
|
|
448
|
+
* convexCollectionOptions<Task>({
|
|
449
|
+
* convexClient,
|
|
450
|
+
* api: api.tasks,
|
|
451
|
+
* collectionName: 'tasks',
|
|
452
|
+
* getKey: (task) => task.id,
|
|
453
|
+
* initialData,
|
|
454
|
+
* })
|
|
455
|
+
* )
|
|
456
|
+
*
|
|
457
|
+
* // Step 2: Wrap with offline support - params automatically extracted!
|
|
458
|
+
* const collection = createConvexCollection(rawCollection)
|
|
459
|
+
*
|
|
460
|
+
* // Use like a normal TanStack DB collection
|
|
461
|
+
* const tx = collection.insert({ id: '1', text: 'Buy milk', isCompleted: false })
|
|
462
|
+
* await tx.isPersisted.promise // Built-in promise (not custom awaitReplication)
|
|
463
|
+
* ```
|
|
464
|
+
*/
|
|
465
|
+
export function createConvexCollection<T extends object>(
|
|
466
|
+
rawCollection: Collection<T>
|
|
467
|
+
): ConvexCollection<T> {
|
|
468
|
+
// Extract config from rawCollection
|
|
469
|
+
const config = (rawCollection as any).config;
|
|
470
|
+
const convexClient = config._convexClient;
|
|
471
|
+
const collectionName = config._collectionName;
|
|
472
|
+
|
|
473
|
+
if (!convexClient || !collectionName) {
|
|
474
|
+
throw new Error(
|
|
475
|
+
'createConvexCollection requires a collection created with convexCollectionOptions. ' +
|
|
476
|
+
'Make sure you pass convexClient and collectionName to convexCollectionOptions.'
|
|
477
|
+
);
|
|
478
|
+
}
|
|
479
|
+
|
|
480
|
+
logger.info('Creating Convex collection with offline support', { collectionName });
|
|
481
|
+
|
|
482
|
+
// Create offline executor (wraps collection ONCE)
|
|
483
|
+
const offline: OfflineExecutor = startOfflineExecutor({
|
|
484
|
+
collections: { [collectionName]: rawCollection as any },
|
|
485
|
+
|
|
486
|
+
// Empty mutationFns - handlers in collection config will be used
|
|
487
|
+
mutationFns: {},
|
|
488
|
+
|
|
489
|
+
// Filter stale transactions before retry
|
|
490
|
+
beforeRetry: (transactions) => {
|
|
491
|
+
const cutoff = Date.now() - 24 * 60 * 60 * 1000; // 24 hours
|
|
492
|
+
const filtered = transactions.filter((tx) => {
|
|
493
|
+
const isRecent = tx.createdAt.getTime() > cutoff;
|
|
494
|
+
const notExhausted = tx.retryCount < 10;
|
|
495
|
+
return isRecent && notExhausted;
|
|
496
|
+
});
|
|
497
|
+
|
|
498
|
+
if (filtered.length < transactions.length) {
|
|
499
|
+
logger.warn('Filtered stale transactions', {
|
|
500
|
+
collectionName,
|
|
501
|
+
before: transactions.length,
|
|
502
|
+
after: filtered.length,
|
|
503
|
+
});
|
|
504
|
+
}
|
|
505
|
+
|
|
506
|
+
return filtered;
|
|
507
|
+
},
|
|
508
|
+
|
|
509
|
+
onLeadershipChange: (isLeader) => {
|
|
510
|
+
logger.info(isLeader ? 'Offline mode active' : 'Online-only mode', {
|
|
511
|
+
collectionName,
|
|
512
|
+
});
|
|
513
|
+
},
|
|
514
|
+
|
|
515
|
+
onStorageFailure: (diagnostic) => {
|
|
516
|
+
logger.warn('Storage failed - online-only mode', {
|
|
517
|
+
collectionName,
|
|
518
|
+
code: diagnostic.code,
|
|
519
|
+
message: diagnostic.message,
|
|
520
|
+
});
|
|
521
|
+
},
|
|
522
|
+
});
|
|
523
|
+
|
|
524
|
+
// Subscribe to Convex connection state for automatic retry trigger
|
|
525
|
+
if (convexClient.connectionState) {
|
|
526
|
+
const connectionState = convexClient.connectionState();
|
|
527
|
+
logger.debug('Initial connection state', {
|
|
528
|
+
collectionName,
|
|
529
|
+
isConnected: connectionState.isWebSocketConnected,
|
|
530
|
+
});
|
|
531
|
+
}
|
|
532
|
+
|
|
533
|
+
// Trigger retry when connection is restored
|
|
534
|
+
if (typeof window !== 'undefined') {
|
|
535
|
+
window.addEventListener('online', () => {
|
|
536
|
+
logger.info('Network online - notifying offline executor', { collectionName });
|
|
537
|
+
offline.notifyOnline();
|
|
538
|
+
});
|
|
539
|
+
}
|
|
540
|
+
|
|
541
|
+
logger.info('Offline support initialized', {
|
|
542
|
+
collectionName,
|
|
543
|
+
mode: offline.mode,
|
|
544
|
+
});
|
|
545
|
+
|
|
546
|
+
// Return collection directly - NO WRAPPER!
|
|
547
|
+
// Users call collection.insert/update/delete as normal
|
|
548
|
+
// Handlers run automatically, offline-transactions handles persistence
|
|
549
|
+
return rawCollection as ConvexCollection<T>;
|
|
550
|
+
}
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Client-side utilities for browser/React code.
|
|
3
|
+
* Import this in your frontend components.
|
|
4
|
+
*
|
|
5
|
+
* @example
|
|
6
|
+
* ```typescript
|
|
7
|
+
* // src/useTasks.ts
|
|
8
|
+
* import {
|
|
9
|
+
* convexCollectionOptions,
|
|
10
|
+
* createConvexCollection,
|
|
11
|
+
* type ConvexCollection,
|
|
12
|
+
* } from '@trestleinc/replicate/client';
|
|
13
|
+
* ```
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
// Component client (ReplicateStorage class)
|
|
17
|
+
export { ReplicateStorage } from './storage.js';
|
|
18
|
+
|
|
19
|
+
// TanStack DB collection integration
|
|
20
|
+
export {
|
|
21
|
+
convexCollectionOptions,
|
|
22
|
+
createConvexCollection,
|
|
23
|
+
type ConvexCollection,
|
|
24
|
+
type ConvexCollectionOptionsConfig,
|
|
25
|
+
} from './collection.js';
|
|
26
|
+
|
|
27
|
+
// Re-export Yjs for convenience
|
|
28
|
+
export * as Y from 'yjs';
|
|
29
|
+
|
|
30
|
+
// Re-export TanStack DB offline utilities
|
|
31
|
+
export { NonRetriableError } from '@tanstack/offline-transactions';
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import {
|
|
2
|
+
type Logger,
|
|
3
|
+
configure,
|
|
4
|
+
getConsoleSink,
|
|
5
|
+
getLogger as getLogTapeLogger,
|
|
6
|
+
} from '@logtape/logtape';
|
|
7
|
+
|
|
8
|
+
let isConfigured = false;
|
|
9
|
+
|
|
10
|
+
export async function configureLogger(enableLogging = false): Promise<void> {
|
|
11
|
+
if (isConfigured) return;
|
|
12
|
+
|
|
13
|
+
await configure({
|
|
14
|
+
sinks: {
|
|
15
|
+
console: getConsoleSink(),
|
|
16
|
+
},
|
|
17
|
+
loggers: [
|
|
18
|
+
{
|
|
19
|
+
category: ['convex-replicate'],
|
|
20
|
+
lowestLevel: enableLogging ? 'debug' : 'warning',
|
|
21
|
+
sinks: ['console'],
|
|
22
|
+
},
|
|
23
|
+
],
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
isConfigured = true;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export function getLogger(category: string[]): Logger {
|
|
30
|
+
return getLogTapeLogger(['convex-replicate', ...category]);
|
|
31
|
+
}
|