@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.
Files changed (39) hide show
  1. package/LICENSE +201 -0
  2. package/LICENSE.package +201 -0
  3. package/README.md +871 -0
  4. package/dist/client/collection.d.ts +94 -0
  5. package/dist/client/index.d.ts +18 -0
  6. package/dist/client/logger.d.ts +3 -0
  7. package/dist/client/storage.d.ts +143 -0
  8. package/dist/component/_generated/api.js +5 -0
  9. package/dist/component/_generated/server.js +9 -0
  10. package/dist/component/convex.config.d.ts +2 -0
  11. package/dist/component/convex.config.js +3 -0
  12. package/dist/component/public.d.ts +99 -0
  13. package/dist/component/public.js +135 -0
  14. package/dist/component/schema.d.ts +22 -0
  15. package/dist/component/schema.js +22 -0
  16. package/dist/index.js +375 -0
  17. package/dist/server/index.d.ts +17 -0
  18. package/dist/server/replication.d.ts +122 -0
  19. package/dist/server/schema.d.ts +73 -0
  20. package/dist/server/ssr.d.ts +79 -0
  21. package/dist/server.js +96 -0
  22. package/dist/ssr.js +19 -0
  23. package/package.json +108 -0
  24. package/src/client/collection.ts +550 -0
  25. package/src/client/index.ts +31 -0
  26. package/src/client/logger.ts +31 -0
  27. package/src/client/storage.ts +206 -0
  28. package/src/component/_generated/api.d.ts +95 -0
  29. package/src/component/_generated/api.js +23 -0
  30. package/src/component/_generated/dataModel.d.ts +60 -0
  31. package/src/component/_generated/server.d.ts +149 -0
  32. package/src/component/_generated/server.js +90 -0
  33. package/src/component/convex.config.ts +3 -0
  34. package/src/component/public.ts +212 -0
  35. package/src/component/schema.ts +16 -0
  36. package/src/server/index.ts +26 -0
  37. package/src/server/replication.ts +244 -0
  38. package/src/server/schema.ts +97 -0
  39. 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
+ }