@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,368 +0,0 @@
1
- import { v } from "convex/values";
2
- import { defineTable, mutationGeneric, queryGeneric } from "convex/server";
3
-
4
- //#region src/shared/types.ts
5
- const SIZE_MULTIPLIERS = {
6
- kb: 1024,
7
- mb: 1024 ** 2,
8
- gb: 1024 ** 3
9
- };
10
- const DURATION_MULTIPLIERS = {
11
- m: 6e4,
12
- h: 36e5,
13
- d: 864e5
14
- };
15
- function parseSize(s) {
16
- const match = /^(\d+)(kb|mb|gb)$/i.exec(s);
17
- if (!match) throw new Error(`Invalid size: ${s}`);
18
- const [, num, unit] = match;
19
- return parseInt(num) * SIZE_MULTIPLIERS[unit.toLowerCase()];
20
- }
21
- function parseDuration(s) {
22
- const match = /^(\d+)(m|h|d)$/i.exec(s);
23
- if (!match) throw new Error(`Invalid duration: ${s}`);
24
- const [, num, unit] = match;
25
- return parseInt(num) * DURATION_MULTIPLIERS[unit.toLowerCase()];
26
- }
27
-
28
- //#endregion
29
- //#region src/server/storage.ts
30
- const BYTES_PER_MB = 1024 * 1024;
31
- const MS_PER_HOUR = 3600 * 1e3;
32
- const DEFAULT_SIZE_THRESHOLD_5MB = 5 * BYTES_PER_MB;
33
- const DEFAULT_PEER_TIMEOUT_24H = 24 * MS_PER_HOUR;
34
- var Replicate = class {
35
- sizeThreshold;
36
- peerTimeout;
37
- constructor(component, collectionName, compaction) {
38
- this.component = component;
39
- this.collectionName = collectionName;
40
- this.sizeThreshold = compaction?.sizeThreshold ? parseSize(compaction.sizeThreshold) : DEFAULT_SIZE_THRESHOLD_5MB;
41
- this.peerTimeout = compaction?.peerTimeout ? parseDuration(compaction.peerTimeout) : DEFAULT_PEER_TIMEOUT_24H;
42
- }
43
- createStreamQuery(opts) {
44
- const component = this.component;
45
- const collection = this.collectionName;
46
- return queryGeneric({
47
- args: {
48
- cursor: v.number(),
49
- limit: v.optional(v.number()),
50
- sizeThreshold: v.optional(v.number())
51
- },
52
- returns: v.object({
53
- changes: v.array(v.object({
54
- documentId: v.string(),
55
- crdtBytes: v.bytes(),
56
- seq: v.number(),
57
- operationType: v.string()
58
- })),
59
- cursor: v.number(),
60
- hasMore: v.boolean(),
61
- compact: v.optional(v.string())
62
- }),
63
- handler: async (ctx, args) => {
64
- if (opts?.evalRead) await opts.evalRead(ctx, collection);
65
- const result = await ctx.runQuery(component.public.stream, {
66
- collection,
67
- cursor: args.cursor,
68
- limit: args.limit,
69
- sizeThreshold: args.sizeThreshold
70
- });
71
- if (opts?.onStream) await opts.onStream(ctx, result);
72
- return result;
73
- }
74
- });
75
- }
76
- createSSRQuery(opts) {
77
- const collection = this.collectionName;
78
- const component = this.component;
79
- return queryGeneric({
80
- args: {},
81
- returns: v.object({
82
- documents: v.any(),
83
- cursor: v.optional(v.number()),
84
- count: v.number(),
85
- crdtBytes: v.optional(v.bytes())
86
- }),
87
- handler: async (ctx) => {
88
- if (opts?.evalRead) await opts.evalRead(ctx, collection);
89
- let docs = await ctx.db.query(collection).collect();
90
- if (opts?.transform) docs = await opts.transform(docs);
91
- const response = {
92
- documents: docs,
93
- count: docs.length
94
- };
95
- if (opts?.includeCRDTState) {
96
- const crdtState = await ctx.runQuery(component.public.getInitialState, { collection });
97
- if (crdtState) {
98
- response.crdtBytes = crdtState.crdtBytes;
99
- response.cursor = crdtState.cursor;
100
- }
101
- }
102
- return response;
103
- }
104
- });
105
- }
106
- createInsertMutation(opts) {
107
- const component = this.component;
108
- const collection = this.collectionName;
109
- return mutationGeneric({
110
- args: {
111
- documentId: v.string(),
112
- crdtBytes: v.bytes(),
113
- materializedDoc: v.any()
114
- },
115
- returns: v.object({
116
- success: v.boolean(),
117
- seq: v.number()
118
- }),
119
- handler: async (ctx, args) => {
120
- const doc = args.materializedDoc;
121
- if (opts?.evalWrite) await opts.evalWrite(ctx, doc);
122
- const result = await ctx.runMutation(component.public.insertDocument, {
123
- collection,
124
- documentId: args.documentId,
125
- crdtBytes: args.crdtBytes
126
- });
127
- await ctx.db.insert(collection, {
128
- id: args.documentId,
129
- ...args.materializedDoc,
130
- timestamp: Date.now()
131
- });
132
- if (opts?.onInsert) await opts.onInsert(ctx, doc);
133
- return {
134
- success: true,
135
- seq: result.seq
136
- };
137
- }
138
- });
139
- }
140
- createUpdateMutation(opts) {
141
- const component = this.component;
142
- const collection = this.collectionName;
143
- return mutationGeneric({
144
- args: {
145
- documentId: v.string(),
146
- crdtBytes: v.bytes(),
147
- materializedDoc: v.any()
148
- },
149
- returns: v.object({
150
- success: v.boolean(),
151
- seq: v.number()
152
- }),
153
- handler: async (ctx, args) => {
154
- const doc = args.materializedDoc;
155
- if (opts?.evalWrite) await opts.evalWrite(ctx, doc);
156
- const result = await ctx.runMutation(component.public.updateDocument, {
157
- collection,
158
- documentId: args.documentId,
159
- crdtBytes: args.crdtBytes
160
- });
161
- const existing = await ctx.db.query(collection).withIndex("by_doc_id", (q) => q.eq("id", args.documentId)).first();
162
- if (existing) await ctx.db.patch(existing._id, {
163
- ...args.materializedDoc,
164
- timestamp: Date.now()
165
- });
166
- if (opts?.onUpdate) await opts.onUpdate(ctx, doc);
167
- return {
168
- success: true,
169
- seq: result.seq
170
- };
171
- }
172
- });
173
- }
174
- createRemoveMutation(opts) {
175
- const component = this.component;
176
- const collection = this.collectionName;
177
- return mutationGeneric({
178
- args: {
179
- documentId: v.string(),
180
- crdtBytes: v.bytes()
181
- },
182
- returns: v.object({
183
- success: v.boolean(),
184
- seq: v.number()
185
- }),
186
- handler: async (ctx, args) => {
187
- const documentId = args.documentId;
188
- if (opts?.evalRemove) await opts.evalRemove(ctx, documentId);
189
- const result = await ctx.runMutation(component.public.deleteDocument, {
190
- collection,
191
- documentId,
192
- crdtBytes: args.crdtBytes
193
- });
194
- const existing = await ctx.db.query(collection).withIndex("by_doc_id", (q) => q.eq("id", documentId)).first();
195
- if (existing) await ctx.db.delete(existing._id);
196
- if (opts?.onRemove) await opts.onRemove(ctx, documentId);
197
- return {
198
- success: true,
199
- seq: result.seq
200
- };
201
- }
202
- });
203
- }
204
- createMarkMutation(opts) {
205
- const component = this.component;
206
- const collection = this.collectionName;
207
- return mutationGeneric({
208
- args: {
209
- peerId: v.string(),
210
- syncedSeq: v.number()
211
- },
212
- returns: v.null(),
213
- handler: async (ctx, args) => {
214
- if (opts?.evalWrite) await opts.evalWrite(ctx, args.peerId);
215
- await ctx.runMutation(component.public.mark, {
216
- collection,
217
- peerId: args.peerId,
218
- syncedSeq: args.syncedSeq
219
- });
220
- return null;
221
- }
222
- });
223
- }
224
- createCompactMutation(opts) {
225
- const component = this.component;
226
- const collection = this.collectionName;
227
- return mutationGeneric({
228
- args: {
229
- documentId: v.string(),
230
- snapshotBytes: v.bytes(),
231
- stateVector: v.bytes(),
232
- peerTimeout: v.optional(v.number())
233
- },
234
- returns: v.object({
235
- success: v.boolean(),
236
- removed: v.number(),
237
- retained: v.number()
238
- }),
239
- handler: async (ctx, args) => {
240
- if (opts?.evalWrite) await opts.evalWrite(ctx, args.documentId);
241
- return await ctx.runMutation(component.public.compact, {
242
- collection,
243
- documentId: args.documentId,
244
- snapshotBytes: args.snapshotBytes,
245
- stateVector: args.stateVector,
246
- peerTimeout: args.peerTimeout
247
- });
248
- }
249
- });
250
- }
251
- createRecoveryQuery(opts) {
252
- const component = this.component;
253
- const collection = this.collectionName;
254
- return queryGeneric({
255
- args: { clientStateVector: v.bytes() },
256
- returns: v.object({
257
- diff: v.optional(v.bytes()),
258
- serverStateVector: v.bytes(),
259
- cursor: v.number()
260
- }),
261
- handler: async (ctx, args) => {
262
- if (opts?.evalRead) await opts.evalRead(ctx, collection);
263
- return await ctx.runQuery(component.public.recovery, {
264
- collection,
265
- clientStateVector: args.clientStateVector
266
- });
267
- }
268
- });
269
- }
270
- };
271
-
272
- //#endregion
273
- //#region src/server/builder.ts
274
- /**
275
- * Create a replicate function bound to your component. Call this once in your
276
- * convex/replicate.ts file, then use the returned function for all collections.
277
- *
278
- * @example
279
- * ```typescript
280
- * // convex/replicate.ts (create once)
281
- * import { replicate } from '@trestleinc/replicate/server';
282
- * import { components } from './_generated/api';
283
- *
284
- * export const tasks = replicate(components.replicate)<Task>({ collection: 'tasks' });
285
- *
286
- * // Or bind once and reuse:
287
- * const r = replicate(components.replicate);
288
- * export const tasks = r<Task>({ collection: 'tasks' });
289
- * export const notebooks = r<Notebook>({ collection: 'notebooks' });
290
- * ```
291
- */
292
- function replicate(component) {
293
- return function boundReplicate(config) {
294
- return replicateInternal(component, config);
295
- };
296
- }
297
- /**
298
- * Internal implementation for replicate.
299
- */
300
- function replicateInternal(component, config) {
301
- const storage = new Replicate(component, config.collection, config.compaction);
302
- return {
303
- __collection: config.collection,
304
- stream: storage.createStreamQuery({
305
- evalRead: config.hooks?.evalRead,
306
- onStream: config.hooks?.onStream
307
- }),
308
- material: storage.createSSRQuery({
309
- evalRead: config.hooks?.evalRead,
310
- transform: config.hooks?.transform
311
- }),
312
- recovery: storage.createRecoveryQuery({ evalRead: config.hooks?.evalRead }),
313
- insert: storage.createInsertMutation({
314
- evalWrite: config.hooks?.evalWrite,
315
- onInsert: config.hooks?.onInsert
316
- }),
317
- update: storage.createUpdateMutation({
318
- evalWrite: config.hooks?.evalWrite,
319
- onUpdate: config.hooks?.onUpdate
320
- }),
321
- remove: storage.createRemoveMutation({
322
- evalRemove: config.hooks?.evalRemove,
323
- onRemove: config.hooks?.onRemove
324
- }),
325
- mark: storage.createMarkMutation({ evalWrite: config.hooks?.evalMark }),
326
- compact: storage.createCompactMutation({ evalWrite: config.hooks?.evalCompact })
327
- };
328
- }
329
-
330
- //#endregion
331
- //#region src/server/schema.ts
332
- const prose = () => v.object({
333
- type: v.literal("doc"),
334
- content: v.optional(v.array(v.any()))
335
- });
336
- /**
337
- * Define a table with automatic timestamp field for replication.
338
- * All replicated tables must have an `id` field and define a `by_doc_id` index.
339
- *
340
- * @example
341
- * ```typescript
342
- * // convex/schema.ts
343
- * export default defineSchema({
344
- * tasks: table(
345
- * { id: v.string(), text: v.string(), isCompleted: v.boolean() },
346
- * (t) => t.index('by_doc_id', ['id']).index('by_completed', ['isCompleted'])
347
- * ),
348
- * });
349
- * ```
350
- */
351
- function table(userFields, applyIndexes) {
352
- const tbl = defineTable({
353
- ...userFields,
354
- timestamp: v.number()
355
- });
356
- if (applyIndexes) return applyIndexes(tbl);
357
- return tbl;
358
- }
359
-
360
- //#endregion
361
- //#region src/server/index.ts
362
- const schema = {
363
- table,
364
- prose
365
- };
366
-
367
- //#endregion
368
- export { replicate, schema };
@@ -1,29 +0,0 @@
1
- //#region src/shared/types.d.ts
2
-
3
- /** ProseMirror-compatible JSON for XmlFragment serialization */
4
- interface XmlFragmentJSON {
5
- type: "doc";
6
- content?: XmlNodeJSON[];
7
- }
8
- declare const PROSE_BRAND: unique symbol;
9
- /**
10
- * Branded prose type for Zod schemas.
11
- * Extends XmlFragmentJSON with a unique brand for type-level detection.
12
- * Use the `prose()` helper from `@trestleinc/replicate/client` to create this type.
13
- */
14
- interface ProseValue extends XmlFragmentJSON {
15
- readonly [PROSE_BRAND]: typeof PROSE_BRAND;
16
- }
17
- /** ProseMirror node structure */
18
- interface XmlNodeJSON {
19
- type: string;
20
- attrs?: Record<string, unknown>;
21
- content?: XmlNodeJSON[];
22
- text?: string;
23
- marks?: {
24
- type: string;
25
- attrs?: Record<string, unknown>;
26
- }[];
27
- }
28
- //#endregion
29
- export { type ProseValue, type XmlFragmentJSON, type XmlNodeJSON };
@@ -1 +0,0 @@
1
- export { };
@@ -1,55 +0,0 @@
1
- import { z } from "zod";
2
- import type { ProseValue } from "$/shared/types";
3
-
4
- const PROSE_MARKER = Symbol.for("replicate:prose");
5
-
6
- function createProseSchema(): z.ZodType<ProseValue> {
7
- const schema = z.custom<ProseValue>(
8
- (val) => {
9
- if (val == null) return true;
10
- if (typeof val !== "object") return false;
11
- return (val as { type?: string }).type === "doc";
12
- },
13
- { message: "Expected prose document with type \"doc\"" },
14
- );
15
-
16
- Object.defineProperty(schema, PROSE_MARKER, { value: true, writable: false });
17
-
18
- return schema;
19
- }
20
-
21
- function emptyProse(): ProseValue {
22
- return { type: "doc", content: [] } as unknown as ProseValue;
23
- }
24
-
25
- export function prose(): z.ZodType<ProseValue> {
26
- return createProseSchema();
27
- }
28
-
29
- prose.empty = emptyProse;
30
-
31
- export function isProseSchema(schema: unknown): boolean {
32
- return (
33
- schema != null
34
- && typeof schema === "object"
35
- && PROSE_MARKER in schema
36
- && (schema as Record<symbol, unknown>)[PROSE_MARKER] === true
37
- );
38
- }
39
-
40
- export function extractProseFields(schema: z.ZodObject<z.ZodRawShape>): string[] {
41
- const fields: string[] = [];
42
-
43
- for (const [key, fieldSchema] of Object.entries(schema.shape)) {
44
- let unwrapped = fieldSchema;
45
- while (unwrapped instanceof z.ZodOptional || unwrapped instanceof z.ZodNullable) {
46
- unwrapped = unwrapped.unwrap();
47
- }
48
-
49
- if (isProseSchema(unwrapped)) {
50
- fields.push(key);
51
- }
52
- }
53
-
54
- return fields;
55
- }
@@ -1,109 +0,0 @@
1
- import { Effect, Context, Layer } from "effect";
2
- import { IDBError, IDBWriteError } from "$/client/errors";
3
- import type { KeyValueStore } from "$/client/persistence/types";
4
-
5
- export type Cursor = number;
6
-
7
- export class CursorService extends Context.Tag("CursorService")<
8
- CursorService,
9
- {
10
- readonly loadCursor: (collection: string) => Effect.Effect<Cursor, IDBError>;
11
- readonly saveCursor: (collection: string, cursor: Cursor) => Effect.Effect<void, IDBWriteError>;
12
- readonly clearCursor: (collection: string) => Effect.Effect<void, IDBError>;
13
- readonly loadPeerId: (collection: string) => Effect.Effect<string, IDBError | IDBWriteError>;
14
- }
15
- >() {}
16
-
17
- function generatePeerId(): string {
18
- return crypto.randomUUID();
19
- }
20
-
21
- export function createCursorLayer(kv: KeyValueStore) {
22
- return Layer.succeed(
23
- CursorService,
24
- CursorService.of({
25
- loadCursor: collection =>
26
- Effect.gen(function* (_) {
27
- const key = `cursor:${collection}`;
28
- const stored = yield* _(
29
- Effect.tryPromise({
30
- try: () => kv.get<Cursor>(key),
31
- catch: cause => new IDBError({ operation: "get", key, cause }),
32
- }),
33
- );
34
-
35
- if (stored !== undefined) {
36
- yield* _(
37
- Effect.logDebug("Loaded cursor from storage", {
38
- collection,
39
- cursor: stored,
40
- }),
41
- );
42
- return stored;
43
- }
44
-
45
- yield* _(
46
- Effect.logDebug("No stored cursor, using default", {
47
- collection,
48
- }),
49
- );
50
- return 0;
51
- }),
52
-
53
- saveCursor: (collection, cursor) =>
54
- Effect.gen(function* (_) {
55
- const key = `cursor:${collection}`;
56
- yield* _(
57
- Effect.tryPromise({
58
- try: () => kv.set(key, cursor),
59
- catch: cause => new IDBWriteError({ key, value: cursor, cause }),
60
- }),
61
- );
62
- yield* _(
63
- Effect.logDebug("Cursor saved", {
64
- collection,
65
- cursor,
66
- }),
67
- );
68
- }),
69
-
70
- clearCursor: collection =>
71
- Effect.gen(function* (_) {
72
- const key = `cursor:${collection}`;
73
- yield* _(
74
- Effect.tryPromise({
75
- try: () => kv.del(key),
76
- catch: cause => new IDBError({ operation: "delete", key, cause }),
77
- }),
78
- );
79
- yield* _(Effect.logDebug("Cursor cleared", { collection }));
80
- }),
81
-
82
- loadPeerId: collection =>
83
- Effect.gen(function* (_) {
84
- const key = `peerId:${collection}`;
85
- const stored = yield* _(
86
- Effect.tryPromise({
87
- try: () => kv.get<string>(key),
88
- catch: cause => new IDBError({ operation: "get", key, cause }),
89
- }),
90
- );
91
-
92
- if (stored) {
93
- yield* _(Effect.logDebug("Loaded peerId from storage", { collection, peerId: stored }));
94
- return stored;
95
- }
96
-
97
- const newPeerId = generatePeerId();
98
- yield* _(
99
- Effect.tryPromise({
100
- try: () => kv.set(key, newPeerId),
101
- catch: cause => new IDBWriteError({ key, value: newPeerId, cause }),
102
- }),
103
- );
104
- yield* _(Effect.logDebug("Generated new peerId", { collection, peerId: newPeerId }));
105
- return newPeerId;
106
- }),
107
- }),
108
- );
109
- }