@trestleinc/replicate 1.1.2 → 1.2.0-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.
Files changed (51) hide show
  1. package/README.md +40 -41
  2. package/package.json +3 -1
  3. package/src/client/collection.ts +334 -523
  4. package/src/client/errors.ts +1 -1
  5. package/src/client/index.ts +4 -7
  6. package/src/client/merge.ts +2 -2
  7. package/src/client/persistence/indexeddb.ts +10 -14
  8. package/src/client/prose.ts +147 -203
  9. package/src/client/services/awareness.ts +373 -0
  10. package/src/client/services/context.ts +114 -0
  11. package/src/client/services/seq.ts +78 -0
  12. package/src/client/services/session.ts +20 -0
  13. package/src/client/services/sync.ts +122 -0
  14. package/src/client/subdocs.ts +263 -0
  15. package/src/component/_generated/api.ts +2 -2
  16. package/src/component/_generated/component.ts +73 -28
  17. package/src/component/mutations.ts +734 -0
  18. package/src/component/schema.ts +31 -14
  19. package/src/server/collection.ts +98 -0
  20. package/src/server/index.ts +2 -2
  21. package/src/server/{storage.ts → replicate.ts} +214 -75
  22. package/dist/client/index.d.ts +0 -314
  23. package/dist/client/index.js +0 -4027
  24. package/dist/component/_generated/api.d.ts +0 -31
  25. package/dist/component/_generated/api.js +0 -25
  26. package/dist/component/_generated/component.d.ts +0 -91
  27. package/dist/component/_generated/component.js +0 -1
  28. package/dist/component/_generated/dataModel.d.ts +0 -42
  29. package/dist/component/_generated/dataModel.js +0 -1
  30. package/dist/component/_generated/server.d.ts +0 -117
  31. package/dist/component/_generated/server.js +0 -73
  32. package/dist/component/_virtual/rolldown_runtime.js +0 -18
  33. package/dist/component/convex.config.d.ts +0 -6
  34. package/dist/component/convex.config.js +0 -8
  35. package/dist/component/logger.d.ts +0 -12
  36. package/dist/component/logger.js +0 -27
  37. package/dist/component/public.d.ts +0 -83
  38. package/dist/component/public.js +0 -325
  39. package/dist/component/schema.d.ts +0 -54
  40. package/dist/component/schema.js +0 -29
  41. package/dist/component/shared/types.d.ts +0 -9
  42. package/dist/component/shared/types.js +0 -15
  43. package/dist/server/index.d.ts +0 -135
  44. package/dist/server/index.js +0 -368
  45. package/dist/shared/index.d.ts +0 -29
  46. package/dist/shared/index.js +0 -1
  47. package/src/client/prose-schema.ts +0 -55
  48. package/src/client/services/cursor.ts +0 -109
  49. package/src/component/public.ts +0 -453
  50. package/src/server/builder.ts +0 -98
  51. /package/src/client/{replicate.ts → ops.ts} +0 -0
@@ -1,453 +0,0 @@
1
- import * as Y from "yjs";
2
- import { v } from "convex/values";
3
- import { mutation, query } from "$/component/_generated/server";
4
- import { getLogger } from "$/component/logger";
5
- import { OperationType } from "$/shared/types";
6
-
7
- export { OperationType };
8
-
9
- const DEFAULT_SIZE_THRESHOLD = 5_000_000;
10
- const DEFAULT_PEER_TIMEOUT = 5 * 60 * 1000;
11
-
12
- async function getNextSeq(ctx: any, collection: string): Promise<number> {
13
- const latest = await ctx.db
14
- .query("documents")
15
- .withIndex("by_seq", (q: any) => q.eq("collection", collection))
16
- .order("desc")
17
- .first();
18
- return (latest?.seq ?? 0) + 1;
19
- }
20
-
21
- export const insertDocument = mutation({
22
- args: {
23
- collection: v.string(),
24
- documentId: v.string(),
25
- crdtBytes: v.bytes(),
26
- },
27
- returns: v.object({
28
- success: v.boolean(),
29
- seq: v.number(),
30
- }),
31
- handler: async (ctx, args) => {
32
- const seq = await getNextSeq(ctx, args.collection);
33
-
34
- await ctx.db.insert("documents", {
35
- collection: args.collection,
36
- documentId: args.documentId,
37
- crdtBytes: args.crdtBytes,
38
- seq,
39
- });
40
-
41
- return { success: true, seq };
42
- },
43
- });
44
-
45
- export const updateDocument = mutation({
46
- args: {
47
- collection: v.string(),
48
- documentId: v.string(),
49
- crdtBytes: v.bytes(),
50
- },
51
- returns: v.object({
52
- success: v.boolean(),
53
- seq: v.number(),
54
- }),
55
- handler: async (ctx, args) => {
56
- const seq = await getNextSeq(ctx, args.collection);
57
-
58
- await ctx.db.insert("documents", {
59
- collection: args.collection,
60
- documentId: args.documentId,
61
- crdtBytes: args.crdtBytes,
62
- seq,
63
- });
64
-
65
- return { success: true, seq };
66
- },
67
- });
68
-
69
- export const deleteDocument = mutation({
70
- args: {
71
- collection: v.string(),
72
- documentId: v.string(),
73
- crdtBytes: v.bytes(),
74
- },
75
- returns: v.object({
76
- success: v.boolean(),
77
- seq: v.number(),
78
- }),
79
- handler: async (ctx, args) => {
80
- const seq = await getNextSeq(ctx, args.collection);
81
-
82
- await ctx.db.insert("documents", {
83
- collection: args.collection,
84
- documentId: args.documentId,
85
- crdtBytes: args.crdtBytes,
86
- seq,
87
- });
88
-
89
- return { success: true, seq };
90
- },
91
- });
92
-
93
- export const mark = mutation({
94
- args: {
95
- collection: v.string(),
96
- peerId: v.string(),
97
- syncedSeq: v.number(),
98
- },
99
- returns: v.null(),
100
- handler: async (ctx, args) => {
101
- const existing = await ctx.db
102
- .query("peers")
103
- .withIndex("by_collection_peer", (q: any) =>
104
- q.eq("collection", args.collection).eq("peerId", args.peerId),
105
- )
106
- .first();
107
-
108
- if (existing) {
109
- await ctx.db.patch(existing._id, {
110
- lastSyncedSeq: Math.max(existing.lastSyncedSeq, args.syncedSeq),
111
- lastSeenAt: Date.now(),
112
- });
113
- }
114
- else {
115
- await ctx.db.insert("peers", {
116
- collection: args.collection,
117
- peerId: args.peerId,
118
- lastSyncedSeq: args.syncedSeq,
119
- lastSeenAt: Date.now(),
120
- });
121
- }
122
-
123
- return null;
124
- },
125
- });
126
-
127
- export const compact = mutation({
128
- args: {
129
- collection: v.string(),
130
- documentId: v.string(),
131
- snapshotBytes: v.bytes(),
132
- stateVector: v.bytes(),
133
- peerTimeout: v.optional(v.number()),
134
- },
135
- returns: v.object({
136
- success: v.boolean(),
137
- removed: v.number(),
138
- retained: v.number(),
139
- }),
140
- handler: async (ctx, args) => {
141
- const logger = getLogger(["compaction"]);
142
- const now = Date.now();
143
- const peerTimeout = args.peerTimeout ?? DEFAULT_PEER_TIMEOUT;
144
- const peerCutoff = now - peerTimeout;
145
-
146
- const deltas = await ctx.db
147
- .query("documents")
148
- .withIndex("by_collection_document", (q: any) =>
149
- q.eq("collection", args.collection).eq("documentId", args.documentId),
150
- )
151
- .collect();
152
-
153
- const activePeers = await ctx.db
154
- .query("peers")
155
- .withIndex("by_collection", (q: any) => q.eq("collection", args.collection))
156
- .filter((q: any) => q.gt(q.field("lastSeenAt"), peerCutoff))
157
- .collect();
158
-
159
- const minSyncedSeq = activePeers.length > 0
160
- ? Math.min(...activePeers.map((p: any) => p.lastSyncedSeq))
161
- : Infinity;
162
-
163
- const existingSnapshot = await ctx.db
164
- .query("snapshots")
165
- .withIndex("by_document", (q: any) =>
166
- q.eq("collection", args.collection).eq("documentId", args.documentId),
167
- )
168
- .first();
169
-
170
- if (existingSnapshot) {
171
- await ctx.db.delete(existingSnapshot._id);
172
- }
173
-
174
- const snapshotSeq = deltas.length > 0
175
- ? Math.max(...deltas.map((d: any) => d.seq))
176
- : 0;
177
-
178
- await ctx.db.insert("snapshots", {
179
- collection: args.collection,
180
- documentId: args.documentId,
181
- snapshotBytes: args.snapshotBytes,
182
- stateVector: args.stateVector,
183
- snapshotSeq,
184
- createdAt: now,
185
- });
186
-
187
- let removed = 0;
188
- for (const delta of deltas) {
189
- if (delta.seq < minSyncedSeq) {
190
- await ctx.db.delete(delta._id);
191
- removed++;
192
- }
193
- }
194
-
195
- logger.info("Compaction completed", {
196
- collection: args.collection,
197
- documentId: args.documentId,
198
- removed,
199
- retained: deltas.length - removed,
200
- activePeers: activePeers.length,
201
- minSyncedSeq,
202
- });
203
-
204
- return { success: true, removed, retained: deltas.length - removed };
205
- },
206
- });
207
-
208
- export const stream = query({
209
- args: {
210
- collection: v.string(),
211
- cursor: v.number(),
212
- limit: v.optional(v.number()),
213
- sizeThreshold: v.optional(v.number()),
214
- },
215
- returns: v.object({
216
- changes: v.array(
217
- v.object({
218
- documentId: v.string(),
219
- crdtBytes: v.bytes(),
220
- seq: v.number(),
221
- operationType: v.string(),
222
- }),
223
- ),
224
- cursor: v.number(),
225
- hasMore: v.boolean(),
226
- compact: v.optional(v.string()),
227
- }),
228
- handler: async (ctx, args) => {
229
- const limit = args.limit ?? 100;
230
- const sizeThreshold = args.sizeThreshold ?? DEFAULT_SIZE_THRESHOLD;
231
-
232
- const documents = await ctx.db
233
- .query("documents")
234
- .withIndex("by_seq", (q: any) =>
235
- q.eq("collection", args.collection).gt("seq", args.cursor),
236
- )
237
- .order("asc")
238
- .take(limit);
239
-
240
- if (documents.length > 0) {
241
- const changes = documents.map((doc: any) => ({
242
- documentId: doc.documentId,
243
- crdtBytes: doc.crdtBytes,
244
- seq: doc.seq,
245
- operationType: OperationType.Delta,
246
- }));
247
-
248
- const newCursor = documents[documents.length - 1]?.seq ?? args.cursor;
249
-
250
- let compactHint: string | undefined;
251
- const allDocs = await ctx.db
252
- .query("documents")
253
- .withIndex("by_collection", (q: any) => q.eq("collection", args.collection))
254
- .collect();
255
-
256
- const sizeByDocument = new Map<string, number>();
257
- for (const doc of allDocs) {
258
- const current = sizeByDocument.get(doc.documentId) ?? 0;
259
- sizeByDocument.set(doc.documentId, current + doc.crdtBytes.byteLength);
260
- }
261
-
262
- for (const [docId, size] of sizeByDocument) {
263
- if (size > sizeThreshold) {
264
- compactHint = docId;
265
- break;
266
- }
267
- }
268
-
269
- return {
270
- changes,
271
- cursor: newCursor,
272
- hasMore: documents.length === limit,
273
- compact: compactHint,
274
- };
275
- }
276
-
277
- const oldestDelta = await ctx.db
278
- .query("documents")
279
- .withIndex("by_seq", (q: any) => q.eq("collection", args.collection))
280
- .order("asc")
281
- .first();
282
-
283
- if (oldestDelta && args.cursor < oldestDelta.seq) {
284
- const snapshots = await ctx.db
285
- .query("snapshots")
286
- .withIndex("by_document", (q: any) => q.eq("collection", args.collection))
287
- .collect();
288
-
289
- if (snapshots.length === 0) {
290
- throw new Error(
291
- `Disparity detected but no snapshots available for collection: ${args.collection}. `
292
- + `Client cursor: ${args.cursor}, Oldest delta seq: ${oldestDelta.seq}`,
293
- );
294
- }
295
-
296
- const changes = snapshots.map((snapshot: any) => ({
297
- documentId: snapshot.documentId,
298
- crdtBytes: snapshot.snapshotBytes,
299
- seq: snapshot.snapshotSeq,
300
- operationType: OperationType.Snapshot,
301
- }));
302
-
303
- const latestSeq = Math.max(...snapshots.map((s: any) => s.snapshotSeq));
304
-
305
- return {
306
- changes,
307
- cursor: latestSeq,
308
- hasMore: false,
309
- compact: undefined,
310
- };
311
- }
312
-
313
- return {
314
- changes: [],
315
- cursor: args.cursor,
316
- hasMore: false,
317
- compact: undefined,
318
- };
319
- },
320
- });
321
-
322
- export const getInitialState = query({
323
- args: {
324
- collection: v.string(),
325
- },
326
- returns: v.union(
327
- v.object({
328
- crdtBytes: v.bytes(),
329
- cursor: v.number(),
330
- }),
331
- v.null(),
332
- ),
333
- handler: async (ctx, args) => {
334
- const logger = getLogger(["ssr"]);
335
-
336
- const snapshots = await ctx.db
337
- .query("snapshots")
338
- .withIndex("by_document", (q: any) => q.eq("collection", args.collection))
339
- .collect();
340
-
341
- const deltas = await ctx.db
342
- .query("documents")
343
- .withIndex("by_collection", (q: any) => q.eq("collection", args.collection))
344
- .collect();
345
-
346
- if (snapshots.length === 0 && deltas.length === 0) {
347
- logger.info("No initial state available - collection is empty", {
348
- collection: args.collection,
349
- });
350
- return null;
351
- }
352
-
353
- const updates: Uint8Array[] = [];
354
- let latestSeq = 0;
355
-
356
- for (const snapshot of snapshots) {
357
- updates.push(new Uint8Array(snapshot.snapshotBytes));
358
- latestSeq = Math.max(latestSeq, snapshot.snapshotSeq);
359
- }
360
-
361
- const sorted = deltas.sort((a: any, b: any) => a.seq - b.seq);
362
- for (const delta of sorted) {
363
- updates.push(new Uint8Array(delta.crdtBytes));
364
- latestSeq = Math.max(latestSeq, delta.seq);
365
- }
366
-
367
- logger.info("Reconstructing initial state", {
368
- collection: args.collection,
369
- snapshotCount: snapshots.length,
370
- deltaCount: deltas.length,
371
- });
372
-
373
- const merged = Y.mergeUpdatesV2(updates);
374
-
375
- logger.info("Initial state reconstructed", {
376
- collection: args.collection,
377
- originalSize: updates.reduce((sum, u) => sum + u.byteLength, 0),
378
- mergedSize: merged.byteLength,
379
- });
380
-
381
- return {
382
- crdtBytes: merged.buffer as ArrayBuffer,
383
- cursor: latestSeq,
384
- };
385
- },
386
- });
387
-
388
- export const recovery = query({
389
- args: {
390
- collection: v.string(),
391
- clientStateVector: v.bytes(),
392
- },
393
- returns: v.object({
394
- diff: v.optional(v.bytes()),
395
- serverStateVector: v.bytes(),
396
- cursor: v.number(),
397
- }),
398
- handler: async (ctx, args) => {
399
- const logger = getLogger(["recovery"]);
400
-
401
- const snapshots = await ctx.db
402
- .query("snapshots")
403
- .withIndex("by_document", (q: any) => q.eq("collection", args.collection))
404
- .collect();
405
-
406
- const deltas = await ctx.db
407
- .query("documents")
408
- .withIndex("by_collection", (q: any) => q.eq("collection", args.collection))
409
- .collect();
410
-
411
- if (snapshots.length === 0 && deltas.length === 0) {
412
- const emptyDoc = new Y.Doc();
413
- const emptyVector = Y.encodeStateVector(emptyDoc);
414
- emptyDoc.destroy();
415
- return {
416
- serverStateVector: emptyVector.buffer as ArrayBuffer,
417
- cursor: 0,
418
- };
419
- }
420
-
421
- const updates: Uint8Array[] = [];
422
- let latestSeq = 0;
423
-
424
- for (const snapshot of snapshots) {
425
- updates.push(new Uint8Array(snapshot.snapshotBytes));
426
- latestSeq = Math.max(latestSeq, snapshot.snapshotSeq);
427
- }
428
-
429
- for (const delta of deltas) {
430
- updates.push(new Uint8Array(delta.crdtBytes));
431
- latestSeq = Math.max(latestSeq, delta.seq);
432
- }
433
-
434
- const mergedState = Y.mergeUpdatesV2(updates);
435
- const clientVector = new Uint8Array(args.clientStateVector);
436
- const diff = Y.diffUpdateV2(mergedState, clientVector);
437
- const serverVector = Y.encodeStateVectorFromUpdateV2(mergedState);
438
-
439
- logger.info("Recovery sync computed", {
440
- collection: args.collection,
441
- snapshotCount: snapshots.length,
442
- deltaCount: deltas.length,
443
- diffSize: diff.byteLength,
444
- hasDiff: diff.byteLength > 0,
445
- });
446
-
447
- return {
448
- diff: diff.byteLength > 0 ? (diff.buffer as ArrayBuffer) : undefined,
449
- serverStateVector: serverVector.buffer as ArrayBuffer,
450
- cursor: latestSeq,
451
- };
452
- },
453
- });
@@ -1,98 +0,0 @@
1
- import type { GenericMutationCtx, GenericQueryCtx, GenericDataModel } from "convex/server";
2
- import { Replicate } from "$/server/storage";
3
- import type { CompactionConfig } from "$/shared/types";
4
-
5
- /**
6
- * Configuration for replicate handlers (without component - used with factory pattern).
7
- */
8
- export interface ReplicateConfig<T extends object> {
9
- collection: string;
10
- compaction?: Partial<CompactionConfig>;
11
- hooks?: {
12
- evalRead?: (ctx: GenericQueryCtx<GenericDataModel>, collection: string) => void | Promise<void>;
13
- evalWrite?: (ctx: GenericMutationCtx<GenericDataModel>, doc: T) => void | Promise<void>;
14
- evalRemove?: (ctx: GenericMutationCtx<GenericDataModel>, docId: string) => void | Promise<void>;
15
- evalMark?: (ctx: GenericMutationCtx<GenericDataModel>, peerId: string) => void | Promise<void>;
16
- evalCompact?: (
17
- ctx: GenericMutationCtx<GenericDataModel>,
18
- documentId: string,
19
- ) => void | Promise<void>;
20
- onStream?: (ctx: GenericQueryCtx<GenericDataModel>, result: any) => void | Promise<void>;
21
- onInsert?: (ctx: GenericMutationCtx<GenericDataModel>, doc: T) => void | Promise<void>;
22
- onUpdate?: (ctx: GenericMutationCtx<GenericDataModel>, doc: T) => void | Promise<void>;
23
- onRemove?: (ctx: GenericMutationCtx<GenericDataModel>, docId: string) => void | Promise<void>;
24
- transform?: (docs: T[]) => T[] | Promise<T[]>;
25
- };
26
- }
27
-
28
- /**
29
- * Create a replicate function bound to your component. Call this once in your
30
- * convex/replicate.ts file, then use the returned function for all collections.
31
- *
32
- * @example
33
- * ```typescript
34
- * // convex/replicate.ts (create once)
35
- * import { replicate } from '@trestleinc/replicate/server';
36
- * import { components } from './_generated/api';
37
- *
38
- * export const tasks = replicate(components.replicate)<Task>({ collection: 'tasks' });
39
- *
40
- * // Or bind once and reuse:
41
- * const r = replicate(components.replicate);
42
- * export const tasks = r<Task>({ collection: 'tasks' });
43
- * export const notebooks = r<Notebook>({ collection: 'notebooks' });
44
- * ```
45
- */
46
- export function replicate(component: any) {
47
- return function boundReplicate<T extends object>(config: ReplicateConfig<T>) {
48
- return replicateInternal<T>(component, config);
49
- };
50
- }
51
-
52
- /**
53
- * Internal implementation for replicate.
54
- */
55
- function replicateInternal<T extends object>(component: any, config: ReplicateConfig<T>) {
56
- const storage = new Replicate<T>(component, config.collection, config.compaction);
57
-
58
- return {
59
- __collection: config.collection,
60
-
61
- stream: storage.createStreamQuery({
62
- evalRead: config.hooks?.evalRead,
63
- onStream: config.hooks?.onStream,
64
- }),
65
-
66
- material: storage.createSSRQuery({
67
- evalRead: config.hooks?.evalRead,
68
- transform: config.hooks?.transform,
69
- }),
70
-
71
- recovery: storage.createRecoveryQuery({
72
- evalRead: config.hooks?.evalRead,
73
- }),
74
-
75
- insert: storage.createInsertMutation({
76
- evalWrite: config.hooks?.evalWrite,
77
- onInsert: config.hooks?.onInsert,
78
- }),
79
-
80
- update: storage.createUpdateMutation({
81
- evalWrite: config.hooks?.evalWrite,
82
- onUpdate: config.hooks?.onUpdate,
83
- }),
84
-
85
- remove: storage.createRemoveMutation({
86
- evalRemove: config.hooks?.evalRemove,
87
- onRemove: config.hooks?.onRemove,
88
- }),
89
-
90
- mark: storage.createMarkMutation({
91
- evalWrite: config.hooks?.evalMark,
92
- }),
93
-
94
- compact: storage.createCompactMutation({
95
- evalWrite: config.hooks?.evalCompact,
96
- }),
97
- };
98
- }
File without changes