@trestleinc/replicate 1.1.1 → 1.1.2-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 (91) hide show
  1. package/README.md +395 -146
  2. package/dist/client/index.d.ts +311 -19
  3. package/dist/client/index.js +4027 -0
  4. package/dist/component/_generated/api.d.ts +13 -17
  5. package/dist/component/_generated/api.js +24 -4
  6. package/dist/component/_generated/component.d.ts +79 -77
  7. package/dist/component/_generated/component.js +1 -0
  8. package/dist/component/_generated/dataModel.d.ts +12 -15
  9. package/dist/component/_generated/dataModel.js +1 -0
  10. package/dist/component/_generated/server.d.ts +19 -22
  11. package/dist/component/_generated/server.js +65 -1
  12. package/dist/component/_virtual/rolldown_runtime.js +18 -0
  13. package/dist/component/convex.config.d.ts +6 -2
  14. package/dist/component/convex.config.js +7 -3
  15. package/dist/component/logger.d.ts +10 -6
  16. package/dist/component/logger.js +25 -28
  17. package/dist/component/public.d.ts +70 -61
  18. package/dist/component/public.js +311 -295
  19. package/dist/component/schema.d.ts +53 -45
  20. package/dist/component/schema.js +26 -32
  21. package/dist/component/shared/types.d.ts +9 -0
  22. package/dist/component/shared/types.js +15 -0
  23. package/dist/server/index.d.ts +134 -13
  24. package/dist/server/index.js +368 -0
  25. package/dist/shared/index.d.ts +27 -3
  26. package/dist/shared/index.js +1 -2
  27. package/package.json +34 -29
  28. package/src/client/collection.ts +339 -306
  29. package/src/client/errors.ts +9 -9
  30. package/src/client/index.ts +13 -32
  31. package/src/client/logger.ts +2 -2
  32. package/src/client/merge.ts +37 -34
  33. package/src/client/persistence/custom.ts +84 -0
  34. package/src/client/persistence/index.ts +9 -46
  35. package/src/client/persistence/indexeddb.ts +111 -84
  36. package/src/client/persistence/memory.ts +3 -3
  37. package/src/client/persistence/sqlite/browser.ts +168 -0
  38. package/src/client/persistence/sqlite/native.ts +29 -0
  39. package/src/client/persistence/sqlite/schema.ts +124 -0
  40. package/src/client/persistence/types.ts +32 -28
  41. package/src/client/prose-schema.ts +55 -0
  42. package/src/client/prose.ts +28 -25
  43. package/src/client/replicate.ts +5 -5
  44. package/src/client/services/cursor.ts +109 -0
  45. package/src/component/_generated/component.ts +31 -29
  46. package/src/component/convex.config.ts +2 -2
  47. package/src/component/logger.ts +7 -7
  48. package/src/component/public.ts +225 -237
  49. package/src/component/schema.ts +18 -15
  50. package/src/server/builder.ts +20 -7
  51. package/src/server/index.ts +3 -5
  52. package/src/server/schema.ts +5 -5
  53. package/src/server/storage.ts +113 -59
  54. package/src/shared/index.ts +5 -5
  55. package/src/shared/types.ts +51 -14
  56. package/dist/client/collection.d.ts +0 -96
  57. package/dist/client/errors.d.ts +0 -59
  58. package/dist/client/logger.d.ts +0 -2
  59. package/dist/client/merge.d.ts +0 -77
  60. package/dist/client/persistence/adapters/index.d.ts +0 -8
  61. package/dist/client/persistence/adapters/opsqlite.d.ts +0 -46
  62. package/dist/client/persistence/adapters/sqljs.d.ts +0 -83
  63. package/dist/client/persistence/index.d.ts +0 -49
  64. package/dist/client/persistence/indexeddb.d.ts +0 -17
  65. package/dist/client/persistence/memory.d.ts +0 -16
  66. package/dist/client/persistence/sqlite-browser.d.ts +0 -51
  67. package/dist/client/persistence/sqlite-level.d.ts +0 -63
  68. package/dist/client/persistence/sqlite-rn.d.ts +0 -36
  69. package/dist/client/persistence/sqlite.d.ts +0 -47
  70. package/dist/client/persistence/types.d.ts +0 -42
  71. package/dist/client/prose.d.ts +0 -56
  72. package/dist/client/replicate.d.ts +0 -40
  73. package/dist/client/services/checkpoint.d.ts +0 -18
  74. package/dist/client/services/reconciliation.d.ts +0 -24
  75. package/dist/index.js +0 -1618
  76. package/dist/server/builder.d.ts +0 -94
  77. package/dist/server/schema.d.ts +0 -27
  78. package/dist/server/storage.d.ts +0 -80
  79. package/dist/server.js +0 -281
  80. package/dist/shared/types.d.ts +0 -50
  81. package/dist/shared/types.js +0 -6
  82. package/dist/shared.js +0 -6
  83. package/src/client/persistence/adapters/index.ts +0 -8
  84. package/src/client/persistence/adapters/opsqlite.ts +0 -54
  85. package/src/client/persistence/adapters/sqljs.ts +0 -128
  86. package/src/client/persistence/sqlite-browser.ts +0 -107
  87. package/src/client/persistence/sqlite-level.ts +0 -407
  88. package/src/client/persistence/sqlite-rn.ts +0 -44
  89. package/src/client/persistence/sqlite.ts +0 -160
  90. package/src/client/services/checkpoint.ts +0 -86
  91. package/src/client/services/reconciliation.ts +0 -108
@@ -1,18 +1,22 @@
1
- import * as Y from 'yjs';
2
- import { createMutex } from 'lib0/mutex';
3
- import type { Persistence, PersistenceProvider } from '$/client/persistence/types.js';
4
- import type { ConvexClient } from 'convex/browser';
5
- import type { FunctionReference } from 'convex/server';
6
- import type { CollectionConfig, Collection } from '@tanstack/db';
7
- import { Effect, Layer } from 'effect';
8
- import { getLogger } from '$/client/logger.js';
9
- import { ProseError, NonRetriableError } from '$/client/errors.js';
10
- import { Checkpoint, createCheckpointLayer } from '$/client/services/checkpoint.js';
11
- import { Reconciliation, ReconciliationLive } from '$/client/services/reconciliation.js';
12
- import { createReplicateOps, type BoundReplicateOps } from '$/client/replicate.js';
1
+ import * as Y from "yjs";
2
+ import { createMutex } from "lib0/mutex";
3
+ import type { Persistence, PersistenceProvider } from "$/client/persistence/types";
4
+ import type { ConvexClient } from "convex/browser";
5
+ import { getFunctionName, type FunctionReference } from "convex/server";
6
+ import {
7
+ createCollection,
8
+ type CollectionConfig,
9
+ type Collection,
10
+ type NonSingleResult,
11
+ type BaseCollectionConfig,
12
+ } from "@tanstack/db";
13
+ import type { StandardSchemaV1 } from "@standard-schema/spec";
14
+ import { Effect } from "effect";
15
+ import { getLogger } from "$/client/logger";
16
+ import { ProseError, NonRetriableError } from "$/client/errors";
17
+ import { CursorService, createCursorLayer, type Cursor } from "$/client/services/cursor";
18
+ import { createReplicateOps, type BoundReplicateOps } from "$/client/replicate";
13
19
  import {
14
- createYjsDocument,
15
- getYMap,
16
20
  transactWithDelta,
17
21
  applyUpdate,
18
22
  extractItems,
@@ -21,18 +25,20 @@ import {
21
25
  fragmentFromJSON,
22
26
  serializeYMapValue,
23
27
  getFragmentFromYMap,
24
- } from '$/client/merge.js';
25
- import * as prose from '$/client/prose.js';
28
+ } from "$/client/merge";
29
+ import * as prose from "$/client/prose";
30
+ import { extractProseFields } from "$/client/prose-schema";
31
+ import { z } from "zod";
26
32
 
27
33
  /** Origin markers for Yjs transactions */
28
34
  enum YjsOrigin {
29
- Local = 'local',
30
- Fragment = 'fragment',
31
- Server = 'server',
35
+ Local = "local",
36
+ Fragment = "fragment",
37
+ Server = "server",
32
38
  }
33
- import type { ProseFields, XmlFragmentJSON } from '$/shared/types.js';
39
+ import type { ProseFields } from "$/shared/types";
34
40
 
35
- const logger = getLogger(['replicate', 'collection']);
41
+ const logger = getLogger(["replicate", "collection"]);
36
42
 
37
43
  interface HttpError extends Error {
38
44
  status?: number;
@@ -62,8 +68,8 @@ interface CollectionTransaction<T> {
62
68
 
63
69
  function handleMutationError(
64
70
  error: unknown,
65
- operation: 'Insert' | 'Update' | 'Delete',
66
- collection: string
71
+ operation: "Insert" | "Update" | "Delete",
72
+ collection: string,
67
73
  ): never {
68
74
  const httpError = error as HttpError;
69
75
  logger.error(`${operation} failed`, {
@@ -73,10 +79,10 @@ function handleMutationError(
73
79
  });
74
80
 
75
81
  if (httpError?.status === 401 || httpError?.status === 403) {
76
- throw new NonRetriableError('Authentication failed');
82
+ throw new NonRetriableError("Authentication failed");
77
83
  }
78
84
  if (httpError?.status === 422) {
79
- throw new NonRetriableError('Validation error');
85
+ throw new NonRetriableError("Validation error");
80
86
  }
81
87
  throw error;
82
88
  }
@@ -84,34 +90,36 @@ function handleMutationError(
84
90
  const cleanupFunctions = new Map<string, () => void>();
85
91
 
86
92
  /** Server-rendered material data for SSR hydration */
87
- export type Materialized<T> = {
88
- documents: ReadonlyArray<T>;
89
- checkpoint?: { lastModified: number };
93
+ export interface Materialized<T> {
94
+ documents: readonly T[];
95
+ cursor?: Cursor;
90
96
  count?: number;
91
97
  crdtBytes?: ArrayBuffer;
92
- };
98
+ }
93
99
 
94
- /** Configuration for creating a Convex-backed collection */
95
- export interface ConvexCollectionOptionsConfig<T extends object> {
96
- getKey: (item: T) => string | number;
97
- material?: Materialized<T>;
100
+ /** API object from replicate() */
101
+ interface ConvexCollectionApi {
102
+ stream: FunctionReference<"query">;
103
+ insert: FunctionReference<"mutation">;
104
+ update: FunctionReference<"mutation">;
105
+ remove: FunctionReference<"mutation">;
106
+ recovery: FunctionReference<"query">;
107
+ mark: FunctionReference<"mutation">;
108
+ compact: FunctionReference<"mutation">;
109
+ material?: FunctionReference<"query">;
110
+ }
111
+
112
+ export interface ConvexCollectionConfig<
113
+ T extends object = object,
114
+ TSchema extends StandardSchemaV1 = never,
115
+ TKey extends string | number = string | number,
116
+ > extends BaseCollectionConfig<T, TKey, TSchema> {
117
+ schema: TSchema;
98
118
  convexClient: ConvexClient;
99
- api: {
100
- stream: FunctionReference<'query'>;
101
- insert: FunctionReference<'mutation'>;
102
- update: FunctionReference<'mutation'>;
103
- remove: FunctionReference<'mutation'>;
104
- recovery: FunctionReference<'query'>;
105
- material?: FunctionReference<'query'>;
106
- [key: string]: any;
107
- };
108
- collection: string;
109
- /** Fields that contain prose (rich text) content stored as Y.XmlFragment */
110
- prose: Array<ProseFields<T>>;
111
- /** Undo capture timeout in ms. Changes within this window merge into one undo. Default: 500 */
112
- undoCaptureTimeout?: number;
113
- /** Persistence provider for Y.Doc and key-value storage */
119
+ api: ConvexCollectionApi;
114
120
  persistence: Persistence;
121
+ material?: Materialized<T>;
122
+ undoCaptureTimeout?: number;
115
123
  }
116
124
 
117
125
  /** Editor binding for BlockNote/TipTap collaboration */
@@ -153,12 +161,6 @@ interface ConvexCollectionUtils<T extends object> {
153
161
  prose(documentId: string, field: ProseFields<T>): Promise<EditorBinding>;
154
162
  }
155
163
 
156
- /** Extended collection with prose field utilities */
157
- export interface ConvexCollection<T extends object> extends Collection<T> {
158
- /** Utilities for prose field operations */
159
- utils: ConvexCollectionUtils<T>;
160
- }
161
-
162
164
  // Module-level storage for Y.Doc and Y.Map instances
163
165
  const collectionDocs = new Map<string, { ydoc: Y.Doc; ymap: Y.Map<unknown> }>();
164
166
 
@@ -217,7 +219,7 @@ function getOrCreateFragmentUndoManager(
217
219
  collection: string,
218
220
  documentId: string,
219
221
  field: string,
220
- fragment: Y.XmlFragment
222
+ fragment: Y.XmlFragment,
221
223
  ): Y.UndoManager {
222
224
  const key = `${collection}:${documentId}:${field}`;
223
225
 
@@ -236,43 +238,53 @@ function getOrCreateFragmentUndoManager(
236
238
  return um;
237
239
  }
238
240
 
239
- /**
240
- * Create TanStack DB collection options with Convex + Yjs replication.
241
- *
242
- * @example
243
- * ```typescript
244
- * const options = convexCollectionOptions<Task>({
245
- * getKey: (t) => t.id,
246
- * convexClient,
247
- * api: { stream: api.tasks.stream, insert: api.tasks.insert, ... },
248
- * collection: 'tasks',
249
- * });
250
- * const collection = createCollection(options);
251
- * ```
252
- */
253
- export function convexCollectionOptions<T extends object>({
254
- getKey,
255
- material,
256
- convexClient,
257
- api,
258
- collection,
259
- prose: proseFields,
260
- undoCaptureTimeout = 500,
261
- persistence,
262
- }: ConvexCollectionOptionsConfig<T>): CollectionConfig<T> & {
263
- _convexClient: ConvexClient;
264
- _collection: string;
265
- _proseFields: Array<ProseFields<T>>;
266
- _persistence: Persistence;
267
- utils: ConvexCollectionUtils<T>;
241
+ export function convexCollectionOptions<
242
+ TSchema extends z.ZodObject<z.ZodRawShape>,
243
+ TKey extends string | number = string | number,
244
+ >(
245
+ config: ConvexCollectionConfig<z.infer<TSchema>, TSchema, TKey>,
246
+ ): CollectionConfig<z.infer<TSchema>, TKey, TSchema, ConvexCollectionUtils<z.infer<TSchema>>> & {
247
+ id: string;
248
+ utils: ConvexCollectionUtils<z.infer<TSchema>>;
249
+ schema: TSchema;
250
+ };
251
+
252
+ export function convexCollectionOptions(
253
+ config: ConvexCollectionConfig<any, any, any>,
254
+ ): CollectionConfig<any, any, any, ConvexCollectionUtils<any>> & {
255
+ id: string;
256
+ utils: ConvexCollectionUtils<any>;
257
+ schema: any;
268
258
  } {
259
+ const {
260
+ schema,
261
+ getKey,
262
+ material,
263
+ convexClient,
264
+ api,
265
+ undoCaptureTimeout = 500,
266
+ persistence,
267
+ } = config;
268
+
269
+ // Extract collection name from function reference path (e.g., "intervals:stream" -> "intervals")
270
+ const functionPath = getFunctionName(api.stream);
271
+ const collection = functionPath.split(":")[0];
272
+ if (!collection) {
273
+ throw new Error("Could not extract collection name from api.stream function reference");
274
+ }
275
+
276
+ const proseFields: string[]
277
+ = schema && schema instanceof z.ZodObject ? extractProseFields(schema) : [];
278
+
279
+ // DataType is 'any' in implementation - type safety comes from overload signatures
280
+ type DataType = any;
269
281
  // Create a Set for O(1) lookup of prose fields
270
- const proseFieldSet = new Set<string>(proseFields as string[]);
282
+ const proseFieldSet = new Set<string>(proseFields);
271
283
 
272
284
  // Create utils object - prose() waits for Y.Doc to be ready via collectionDocs
273
- const utils: ConvexCollectionUtils<T> = {
274
- async prose(documentId: string, field: ProseFields<T>): Promise<EditorBinding> {
275
- const fieldStr = field as string;
285
+ const utils: ConvexCollectionUtils<DataType> = {
286
+ async prose(documentId: string, field: ProseFields<DataType>): Promise<EditorBinding> {
287
+ const fieldStr = field;
276
288
 
277
289
  // Validate field is in prose config
278
290
  if (!proseFieldSet.has(fieldStr)) {
@@ -295,14 +307,15 @@ export function convexCollectionOptions<T extends object>({
295
307
  if (collectionDocs.has(collection)) {
296
308
  clearInterval(check);
297
309
  resolve();
298
- } else if (Date.now() - startTime > maxWait) {
310
+ }
311
+ else if (Date.now() - startTime > maxWait) {
299
312
  clearInterval(check);
300
313
  reject(
301
314
  new ProseError({
302
315
  documentId,
303
316
  field: fieldStr,
304
317
  collection,
305
- })
318
+ }),
306
319
  );
307
320
  }
308
321
  }, 10);
@@ -346,7 +359,7 @@ export function convexCollectionOptions<T extends object>({
346
359
  collection,
347
360
  documentId,
348
361
  fieldStr,
349
- fragment
362
+ fragment,
350
363
  );
351
364
 
352
365
  // Return EditorBinding with reactive pending state from prose module
@@ -381,17 +394,21 @@ export function convexCollectionOptions<T extends object>({
381
394
  },
382
395
  };
383
396
 
384
- let ydoc: Y.Doc = null as any;
385
- let ymap: Y.Map<unknown> = null as any;
397
+ // Create ydoc/ymap synchronously for immediate local-first operations
398
+ // Persistence sync will load state into this doc later
399
+ const ydoc: Y.Doc = new Y.Doc({ guid: collection } as any);
400
+ const ymap: Y.Map<unknown> = ydoc.getMap(collection);
386
401
  let docPersistence: PersistenceProvider = null as any;
387
402
 
403
+ // Register ydoc immediately so utils.prose() can access it
404
+ collectionDocs.set(collection, { ydoc, ymap });
405
+
388
406
  // Bound replicate operations - set during sync initialization
389
407
  // Used by onDelete and other handlers that need to sync with TanStack DB
390
- let ops: BoundReplicateOps<T> = null as any;
408
+ let ops: BoundReplicateOps<DataType> = null as any;
391
409
 
392
410
  // Create services layer with the persistence KV store
393
- const checkpointLayer = createCheckpointLayer(persistence.kv);
394
- const servicesLayer = Layer.mergeAll(checkpointLayer, ReconciliationLive);
411
+ const cursorLayer = createCursorLayer(persistence.kv);
395
412
 
396
413
  let resolvePersistenceReady: (() => void) | undefined;
397
414
  const persistenceReadyPromise = new Promise<void>((resolve) => {
@@ -403,94 +420,47 @@ export function convexCollectionOptions<T extends object>({
403
420
  resolveOptimisticReady = resolve;
404
421
  });
405
422
 
406
- const reconcile = (ops: BoundReplicateOps<T>) =>
407
- Effect.gen(function* () {
408
- if (!api.material) return;
409
-
410
- const materialApi = api.material;
411
- const reconciliation = yield* Reconciliation;
412
-
413
- const serverResponse = yield* Effect.tryPromise({
414
- try: () => convexClient.query(materialApi, {}),
415
- catch: (error) => new Error(`Reconciliation query failed: ${error}`),
416
- });
417
-
418
- const serverDocs = Array.isArray(serverResponse)
419
- ? serverResponse
420
- : ((serverResponse as any).documents as T[] | undefined) || [];
421
-
422
- const removedItems = yield* reconciliation.reconcile(
423
- ydoc,
424
- ymap,
425
- collection,
426
- serverDocs,
427
- (doc: T) => String(getKey(doc))
428
- );
429
-
430
- if (removedItems.length > 0) {
431
- ops.delete(removedItems);
432
- }
433
- }).pipe(
434
- Effect.catchAll((error) =>
435
- Effect.gen(function* () {
436
- yield* Effect.logError('Reconciliation failed', { collection, error });
437
- })
438
- )
439
- );
440
-
441
- /**
442
- * Recovery sync using state vectors.
443
- * Fetches missing data from server based on local state vector.
444
- */
445
- const recoverSync = async (): Promise<void> => {
423
+ const recover = async (): Promise<Cursor> => {
446
424
  if (!api.recovery) {
447
- logger.debug('No recovery API configured, skipping recovery sync', { collection });
448
- return;
425
+ logger.debug("No recovery API configured", { collection });
426
+ return 0;
449
427
  }
450
428
 
451
429
  try {
452
- // Encode local state vector
453
430
  const localStateVector = Y.encodeStateVector(ydoc);
454
-
455
- logger.debug('Starting recovery sync', {
431
+ logger.debug("Starting recovery", {
456
432
  collection,
457
433
  localVectorSize: localStateVector.byteLength,
458
434
  });
459
435
 
460
- // Query server for diff
461
436
  const response = await convexClient.query(api.recovery, {
462
437
  clientStateVector: localStateVector.buffer as ArrayBuffer,
463
438
  });
464
439
 
465
- // Apply diff if any
466
440
  if (response.diff) {
467
441
  const mux = getOrCreateMutex(collection);
468
442
  mux(() => {
469
443
  applyUpdate(ydoc, new Uint8Array(response.diff), YjsOrigin.Server);
470
444
  });
471
-
472
- logger.info('Recovery sync applied diff', {
473
- collection,
474
- diffSize: response.diff.byteLength,
475
- });
476
- } else {
477
- logger.debug('Recovery sync - no diff needed', { collection });
445
+ logger.info("Recovery applied diff", { collection, diffSize: response.diff.byteLength });
478
446
  }
479
447
 
480
- // Store server state vector for future reference
481
448
  if (response.serverStateVector) {
482
449
  serverStateVectors.set(collection, new Uint8Array(response.serverStateVector));
483
450
  }
484
- } catch (error) {
485
- logger.error('Recovery sync failed', {
486
- collection,
487
- error: String(error),
488
- });
489
- // Don't throw - recovery is best-effort, subscription will catch up
451
+
452
+ const cursor = response.cursor ?? 0;
453
+ await persistence.kv.set(`cursor:${collection}`, cursor);
454
+ logger.info("Recovery complete", { collection, cursor });
455
+ return cursor;
456
+ }
457
+ catch (error) {
458
+ logger.error("Recovery failed", { collection, error: String(error) });
459
+ return 0;
490
460
  }
491
461
  };
492
462
 
493
- const applyYjsInsert = (mutations: CollectionMutation<T>[]): Uint8Array => {
463
+ const applyYjsInsert = (mutations: CollectionMutation<DataType>[]): Uint8Array => {
494
464
  const { delta } = transactWithDelta(
495
465
  ydoc,
496
466
  () => {
@@ -505,19 +475,20 @@ export function convexCollectionOptions<T extends object>({
505
475
  // Add fragment to map FIRST (binds it to the Y.Doc)
506
476
  itemYMap.set(k, fragment);
507
477
  // THEN populate content (now it's part of the document)
508
- fragmentFromJSON(fragment, v as XmlFragmentJSON);
509
- } else {
478
+ fragmentFromJSON(fragment, v);
479
+ }
480
+ else {
510
481
  itemYMap.set(k, v);
511
482
  }
512
483
  });
513
484
  });
514
485
  },
515
- YjsOrigin.Local
486
+ YjsOrigin.Local,
516
487
  );
517
488
  return delta;
518
489
  };
519
490
 
520
- const applyYjsUpdate = (mutations: CollectionMutation<T>[]): Uint8Array => {
491
+ const applyYjsUpdate = (mutations: CollectionMutation<DataType>[]): Uint8Array => {
521
492
  const { delta } = transactWithDelta(
522
493
  ydoc,
523
494
  () => {
@@ -526,7 +497,7 @@ export function convexCollectionOptions<T extends object>({
526
497
  if (itemYMap) {
527
498
  const modifiedFields = mut.modified as Record<string, unknown>;
528
499
  if (!modifiedFields) {
529
- logger.warn('mut.modified is null/undefined', { collection, key: String(mut.key) });
500
+ logger.warn("mut.modified is null/undefined", { collection, key: String(mut.key) });
530
501
  return;
531
502
  }
532
503
  Object.entries(modifiedFields).forEach(([k, v]) => {
@@ -537,33 +508,34 @@ export function convexCollectionOptions<T extends object>({
537
508
  // Server sync goes: subscription → applyUpdate(ydoc) → CRDT merge
538
509
  // Writing serialized JSON back would corrupt the CRDT state
539
510
  if (proseFieldSet.has(k)) {
540
- logger.debug('Skipping prose field in applyYjsUpdate', { field: k });
511
+ logger.debug("Skipping prose field in applyYjsUpdate", { field: k });
541
512
  return;
542
513
  }
543
514
 
544
515
  // Also skip if existing value is a Y.XmlFragment (defensive check)
545
516
  if (existingValue instanceof Y.XmlFragment) {
546
- logger.debug('Preserving live fragment field', { field: k });
517
+ logger.debug("Preserving live fragment field", { field: k });
547
518
  return;
548
519
  }
549
520
 
550
521
  // Regular field update
551
522
  itemYMap.set(k, v);
552
523
  });
553
- } else {
554
- logger.error('Update attempted on non-existent item', {
524
+ }
525
+ else {
526
+ logger.error("Update attempted on non-existent item", {
555
527
  collection,
556
528
  key: String(mut.key),
557
529
  });
558
530
  }
559
531
  });
560
532
  },
561
- YjsOrigin.Local
533
+ YjsOrigin.Local,
562
534
  );
563
535
  return delta;
564
536
  };
565
537
 
566
- const applyYjsDelete = (mutations: CollectionMutation<T>[]): Uint8Array => {
538
+ const applyYjsDelete = (mutations: CollectionMutation<DataType>[]): Uint8Array => {
567
539
  const { delta } = transactWithDelta(
568
540
  ydoc,
569
541
  () => {
@@ -571,7 +543,7 @@ export function convexCollectionOptions<T extends object>({
571
543
  ymap.delete(String(mut.key));
572
544
  });
573
545
  },
574
- YjsOrigin.Local
546
+ YjsOrigin.Local,
575
547
  );
576
548
  return delta;
577
549
  };
@@ -579,20 +551,17 @@ export function convexCollectionOptions<T extends object>({
579
551
  return {
580
552
  id: collection,
581
553
  getKey,
582
- _convexClient: convexClient,
583
- _collection: collection,
584
- _proseFields: proseFields,
585
- _persistence: persistence,
554
+ schema: schema,
586
555
  utils,
587
556
 
588
- onInsert: async ({ transaction }: CollectionTransaction<T>) => {
557
+ onInsert: async ({ transaction }: CollectionTransaction<DataType>) => {
558
+ const delta = applyYjsInsert(transaction.mutations);
559
+
589
560
  try {
590
561
  await Promise.all([persistenceReadyPromise, optimisticReadyPromise]);
591
- const delta = applyYjsInsert(transaction.mutations);
592
562
  if (delta.length > 0) {
593
563
  const documentKey = String(transaction.mutations[0].key);
594
564
  const itemYMap = ymap.get(documentKey) as Y.Map<unknown>;
595
- // Use serializeYMapValue to convert Y.XmlFragment → XmlFragmentJSON (same as onUpdate)
596
565
  const materializedDoc = itemYMap
597
566
  ? serializeYMapValue(itemYMap)
598
567
  : transaction.mutations[0].modified;
@@ -602,32 +571,33 @@ export function convexCollectionOptions<T extends object>({
602
571
  materializedDoc,
603
572
  });
604
573
  }
605
- } catch (error) {
606
- handleMutationError(error, 'Insert', collection);
574
+ }
575
+ catch (error) {
576
+ handleMutationError(error, "Insert", collection);
607
577
  }
608
578
  },
609
579
 
610
- onUpdate: async ({ transaction }: CollectionTransaction<T>) => {
611
- try {
612
- const mutation = transaction.mutations[0];
613
- const documentKey = String(mutation.key);
580
+ onUpdate: async ({ transaction }: CollectionTransaction<DataType>) => {
581
+ const mutation = transaction.mutations[0];
582
+ const documentKey = String(mutation.key);
614
583
 
615
- // Skip if this update originated from server (prevents echo loops)
616
- // Now checks DOCUMENT-level flag, not collection-level
617
- if (prose.isApplyingFromServer(collection, documentKey)) {
618
- logger.debug('Skipping onUpdate - data from server', { collection, documentKey });
619
- return;
620
- }
584
+ if (prose.isApplyingFromServer(collection, documentKey)) {
585
+ logger.debug("Skipping onUpdate - data from server", { collection, documentKey });
586
+ return;
587
+ }
621
588
 
622
- await Promise.all([persistenceReadyPromise, optimisticReadyPromise]);
589
+ const metadata = mutation.metadata as { contentSync?: ContentSyncMetadata } | undefined;
590
+ const isContentSync = !!metadata?.contentSync;
623
591
 
624
- // Metadata is on mutation, not transaction (TanStack DB API)
625
- const metadata = mutation.metadata as { contentSync?: ContentSyncMetadata } | undefined;
592
+ // Apply regular updates to Yjs immediately (local-first)
593
+ // ContentSync updates already have Yjs changes applied via fragment observer
594
+ const delta = isContentSync ? null : applyYjsUpdate(transaction.mutations);
626
595
 
627
- // Check if this is a content sync from utils.prose()
628
- if (metadata?.contentSync) {
629
- const { crdtBytes, materializedDoc } = metadata.contentSync;
596
+ try {
597
+ await Promise.all([persistenceReadyPromise, optimisticReadyPromise]);
630
598
 
599
+ if (isContentSync && metadata?.contentSync) {
600
+ const { crdtBytes, materializedDoc } = metadata.contentSync;
631
601
  await convexClient.mutation(api.update, {
632
602
  documentId: documentKey,
633
603
  crdtBytes,
@@ -636,11 +606,8 @@ export function convexCollectionOptions<T extends object>({
636
606
  return;
637
607
  }
638
608
 
639
- // Regular update - apply to Y.Doc and generate delta
640
- const delta = applyYjsUpdate(transaction.mutations);
641
- if (delta.length > 0) {
609
+ if (delta && delta.length > 0) {
642
610
  const itemYMap = ymap.get(documentKey) as Y.Map<unknown>;
643
- // Use serializeYMapValue to properly handle XmlFragment fields
644
611
  const fullDoc = itemYMap ? serializeYMapValue(itemYMap) : mutation.modified;
645
612
  await convexClient.mutation(api.update, {
646
613
  documentId: documentKey,
@@ -648,18 +615,20 @@ export function convexCollectionOptions<T extends object>({
648
615
  materializedDoc: fullDoc,
649
616
  });
650
617
  }
651
- } catch (error) {
652
- handleMutationError(error, 'Update', collection);
618
+ }
619
+ catch (error) {
620
+ handleMutationError(error, "Update", collection);
653
621
  }
654
622
  },
655
623
 
656
- onDelete: async ({ transaction }: CollectionTransaction<T>) => {
624
+ onDelete: async ({ transaction }: CollectionTransaction<DataType>) => {
625
+ const delta = applyYjsDelete(transaction.mutations);
626
+
657
627
  try {
658
628
  await Promise.all([persistenceReadyPromise, optimisticReadyPromise]);
659
- const delta = applyYjsDelete(transaction.mutations);
660
629
  const itemsToDelete = transaction.mutations
661
- .map((mut) => mut.original)
662
- .filter((item): item is T => item !== undefined && Object.keys(item).length > 0);
630
+ .map(mut => mut.original)
631
+ .filter((item): item is DataType => item !== undefined && Object.keys(item).length > 0);
663
632
  ops.delete(itemsToDelete);
664
633
  if (delta.length > 0) {
665
634
  const documentKey = String(transaction.mutations[0].key);
@@ -668,13 +637,14 @@ export function convexCollectionOptions<T extends object>({
668
637
  crdtBytes: delta.slice().buffer,
669
638
  });
670
639
  }
671
- } catch (error) {
672
- handleMutationError(error, 'Delete', collection);
640
+ }
641
+ catch (error) {
642
+ handleMutationError(error, "Delete", collection);
673
643
  }
674
644
  },
675
645
 
676
646
  sync: {
677
- rowUpdateMode: 'partial',
647
+ rowUpdateMode: "partial",
678
648
  sync: (params: any) => {
679
649
  const { markReady, collection: collectionInstance } = params;
680
650
 
@@ -689,35 +659,31 @@ export function convexCollectionOptions<T extends object>({
689
659
 
690
660
  let subscription: (() => void) | null = null;
691
661
  const ssrDocuments = material?.documents;
692
- const ssrCheckpoint = material?.checkpoint;
662
+ const ssrCursor = material?.cursor;
693
663
  const ssrCRDTBytes = material?.crdtBytes;
694
- const docs: T[] = ssrDocuments ? [...ssrDocuments] : [];
664
+ const docs: DataType[] = ssrDocuments ? [...ssrDocuments] : [];
695
665
 
696
666
  (async () => {
697
667
  try {
698
- ydoc = await createYjsDocument(collection, persistence.kv);
699
- ymap = getYMap<unknown>(ydoc, collection);
700
-
701
- collectionDocs.set(collection, { ydoc, ymap });
702
-
703
- // Store undo config for per-document undo managers
668
+ // ydoc/ymap already created synchronously - just set up undo config
704
669
  const trackedOrigins = new Set([YjsOrigin.Local]);
705
670
  collectionUndoConfig.set(collection, {
706
671
  captureTimeout: undoCaptureTimeout,
707
672
  trackedOrigins,
708
673
  });
709
674
 
675
+ // Load persisted state into existing ydoc
710
676
  docPersistence = persistence.createDocPersistence(collection, ydoc);
711
677
  docPersistence.whenSynced.then(() => {
712
- logger.debug('Persistence synced', { collection });
678
+ logger.debug("Persistence synced", { collection });
713
679
  resolvePersistenceReady?.();
714
680
  });
715
681
  await persistenceReadyPromise;
716
- logger.info('Persistence ready', { collection, ymapSize: ymap.size });
682
+ logger.info("Persistence ready", { collection, ymapSize: ymap.size });
717
683
 
718
684
  // Create bound replicate operations for this collection
719
685
  // These are tied to this collection's TanStack DB params
720
- ops = createReplicateOps<T>(params);
686
+ ops = createReplicateOps<DataType>(params);
721
687
  resolveOptimisticReady?.();
722
688
 
723
689
  // Note: Fragment sync is handled by utils.prose() debounce handler
@@ -727,55 +693,37 @@ export function convexCollectionOptions<T extends object>({
727
693
  applyUpdate(ydoc, new Uint8Array(ssrCRDTBytes), YjsOrigin.Server);
728
694
  }
729
695
 
730
- // === LOCAL-FIRST FLOW WITH RECOVERY ===
731
- // 1. Local data (IndexedDB/Yjs) is the source of truth
732
- // 2. Recovery sync - get any missing data from server using state vectors
733
- // 3. Push local+recovered data to TanStack DB with ops.replace
734
- // 4. Reconcile phantom documents (hidden in loading state)
735
- // 5. markReady() - UI renders DATA immediately
736
- // 6. Subscription starts in background (replication)
737
-
738
- // Step 1: Recovery sync - fetch missing server data
739
- await recoverSync();
696
+ const recoveryCursor = await recover();
740
697
 
741
- // Step 2: Push local+recovered data to TanStack DB
742
698
  if (ymap.size > 0) {
743
- const items = extractItems<T>(ymap);
744
- ops.replace(items); // Atomic replace, not accumulative insert
745
- logger.info('Data loaded to TanStack DB', {
699
+ const items = extractItems<DataType>(ymap);
700
+ ops.replace(items);
701
+ logger.info("Data loaded to TanStack DB", {
746
702
  collection,
747
703
  itemCount: items.length,
748
704
  });
749
- } else {
750
- // No data - clear TanStack DB to avoid stale state
705
+ }
706
+ else {
751
707
  ops.replace([]);
752
- logger.info('No data, cleared TanStack DB', { collection });
708
+ logger.info("No data, cleared TanStack DB", { collection });
753
709
  }
754
710
 
755
- // Step 3: Reconcile phantom documents (still in loading state)
756
- logger.debug('Running reconciliation', { collection, ymapSize: ymap.size });
757
- await Effect.runPromise(reconcile(ops).pipe(Effect.provide(servicesLayer)));
758
- logger.debug('Reconciliation complete', { collection });
759
-
760
- // Step 4: Mark ready - UI shows data immediately
761
711
  markReady();
762
- logger.info('Collection ready', { collection, ymapSize: ymap.size });
763
-
764
- // Step 4: Load checkpoint for subscription (background replication)
765
- const checkpoint =
766
- ssrCheckpoint ||
767
- (await Effect.runPromise(
768
- Effect.gen(function* () {
769
- const checkpointSvc = yield* Checkpoint;
770
- return yield* checkpointSvc.loadCheckpoint(collection);
771
- }).pipe(Effect.provide(checkpointLayer))
772
- ));
773
-
774
- logger.info('Checkpoint loaded', {
712
+ logger.info("Collection ready", { collection, ymapSize: ymap.size });
713
+
714
+ const peerId = await Effect.runPromise(
715
+ Effect.gen(function* () {
716
+ const cursorSvc = yield* CursorService;
717
+ return yield* cursorSvc.loadPeerId(collection);
718
+ }).pipe(Effect.provide(cursorLayer)),
719
+ );
720
+ const cursor = ssrCursor ?? recoveryCursor;
721
+
722
+ logger.info("Starting subscription", {
775
723
  collection,
776
- checkpoint,
777
- source: ssrCheckpoint ? 'SSR' : 'IndexedDB',
778
- ymapSize: ymap.size,
724
+ cursor,
725
+ peerId,
726
+ source: ssrCursor !== undefined ? "SSR" : "recovery",
779
727
  });
780
728
 
781
729
  // Get mutex for thread-safe updates
@@ -787,16 +735,17 @@ export function convexCollectionOptions<T extends object>({
787
735
 
788
736
  mux(() => {
789
737
  try {
790
- logger.debug('Applying snapshot', {
738
+ logger.debug("Applying snapshot", {
791
739
  collection,
792
740
  bytesLength: crdtBytes.byteLength,
793
741
  });
794
742
  applyUpdate(ydoc, new Uint8Array(crdtBytes), YjsOrigin.Server);
795
- const items = extractItems<T>(ymap);
796
- logger.debug('Snapshot applied', { collection, itemCount: items.length });
743
+ const items = extractItems<DataType>(ymap);
744
+ logger.debug("Snapshot applied", { collection, itemCount: items.length });
797
745
  ops.replace(items);
798
- } catch (error) {
799
- logger.error('Error applying snapshot', { collection, error: String(error) });
746
+ }
747
+ catch (error) {
748
+ logger.error("Error applying snapshot", { collection, error: String(error) });
800
749
  throw new Error(`Snapshot application failed: ${error}`);
801
750
  }
802
751
  });
@@ -812,38 +761,42 @@ export function convexCollectionOptions<T extends object>({
812
761
 
813
762
  mux(() => {
814
763
  try {
815
- logger.debug('Applying delta', {
764
+ logger.debug("Applying delta", {
816
765
  collection,
817
766
  documentId,
818
767
  bytesLength: crdtBytes.byteLength,
819
768
  });
820
769
 
821
- const itemBefore = documentId ? extractItem<T>(ymap, documentId) : null;
770
+ const itemBefore = documentId ? extractItem<DataType>(ymap, documentId) : null;
822
771
  applyUpdate(ydoc, new Uint8Array(crdtBytes), YjsOrigin.Server);
823
772
 
824
773
  if (!documentId) {
825
- logger.debug('Delta applied (no documentId)', { collection });
774
+ logger.debug("Delta applied (no documentId)", { collection });
826
775
  return;
827
776
  }
828
777
 
829
- const itemAfter = extractItem<T>(ymap, documentId);
778
+ const itemAfter = extractItem<DataType>(ymap, documentId);
830
779
  if (itemAfter) {
831
- logger.debug('Upserting item after delta', { collection, documentId });
780
+ logger.debug("Upserting item after delta", { collection, documentId });
832
781
  ops.upsert([itemAfter]);
833
- } else if (itemBefore) {
834
- logger.debug('Deleting item after delta', { collection, documentId });
782
+ }
783
+ else if (itemBefore) {
784
+ logger.debug("Deleting item after delta", { collection, documentId });
835
785
  ops.delete([itemBefore]);
836
- } else {
837
- logger.debug('No change detected after delta', { collection, documentId });
838
786
  }
839
- } catch (error) {
840
- logger.error('Error applying delta', {
787
+ else {
788
+ logger.debug("No change detected after delta", { collection, documentId });
789
+ }
790
+ }
791
+ catch (error) {
792
+ logger.error("Error applying delta", {
841
793
  collection,
842
794
  documentId,
843
795
  error: String(error),
844
796
  });
845
797
  throw new Error(`Delta application failed for ${documentId}: ${error}`);
846
- } finally {
798
+ }
799
+ finally {
847
800
  // Clear document-level flag after delta processing
848
801
  if (documentId) {
849
802
  prose.setApplyingFromServer(collection, documentId, false);
@@ -852,86 +805,111 @@ export function convexCollectionOptions<T extends object>({
852
805
  });
853
806
  };
854
807
 
855
- // Simple async subscription handler - bypasses Effect for reliability
856
808
  const handleSubscriptionUpdate = async (response: any) => {
857
809
  try {
858
- // Validate response shape
859
810
  if (!response || !Array.isArray(response.changes)) {
860
- logger.error('Invalid subscription response', { response });
811
+ logger.error("Invalid subscription response", { response });
861
812
  return;
862
813
  }
863
814
 
864
- const { changes, checkpoint: newCheckpoint } = response;
815
+ const { changes, cursor: newCursor, compact: compactHint } = response;
865
816
 
866
- // Process each change
867
817
  for (const change of changes) {
868
818
  const { operationType, crdtBytes, documentId } = change;
869
819
  if (!crdtBytes) {
870
- logger.warn('Skipping change with missing crdtBytes', { change });
820
+ logger.warn("Skipping change with missing crdtBytes", { change });
871
821
  continue;
872
822
  }
873
823
 
874
824
  try {
875
- if (operationType === 'snapshot') {
825
+ if (operationType === "snapshot") {
876
826
  handleSnapshotChange(crdtBytes);
877
- } else {
827
+ }
828
+ else {
878
829
  handleDeltaChange(crdtBytes, documentId);
879
830
  }
880
- } catch (changeError) {
881
- logger.error('Failed to apply change', {
831
+ }
832
+ catch (changeError) {
833
+ logger.error("Failed to apply change", {
882
834
  operationType,
883
835
  documentId,
884
836
  error: String(changeError),
885
837
  });
886
- // Continue processing other changes
887
838
  }
888
839
  }
889
840
 
890
- // Save checkpoint using persistence KV store
891
- if (newCheckpoint) {
841
+ if (newCursor !== undefined) {
842
+ try {
843
+ const key = `cursor:${collection}`;
844
+ await persistence.kv.set(key, newCursor);
845
+ logger.debug("Cursor saved", { collection, cursor: newCursor });
846
+
847
+ await convexClient.mutation(api.mark, {
848
+ peerId,
849
+ syncedSeq: newCursor,
850
+ });
851
+ logger.debug("Ack sent", { collection, peerId, syncedSeq: newCursor });
852
+ }
853
+ catch (ackError) {
854
+ logger.error("Failed to save cursor or ack", {
855
+ collection,
856
+ error: String(ackError),
857
+ });
858
+ }
859
+ }
860
+
861
+ if (compactHint) {
892
862
  try {
893
- const key = `checkpoint:${collection}`;
894
- await persistence.kv.set(key, newCheckpoint);
895
- logger.debug('Checkpoint saved', { collection, checkpoint: newCheckpoint });
896
- } catch (checkpointError) {
897
- logger.error('Failed to save checkpoint', {
863
+ const snapshot = Y.encodeStateAsUpdate(ydoc);
864
+ const stateVector = Y.encodeStateVector(ydoc);
865
+ await convexClient.mutation(api.compact, {
866
+ documentId: compactHint,
867
+ snapshotBytes: snapshot.buffer,
868
+ stateVector: stateVector.buffer,
869
+ });
870
+ logger.info("Compaction triggered", { collection, documentId: compactHint });
871
+ }
872
+ catch (compactError) {
873
+ logger.error("Compaction failed", {
898
874
  collection,
899
- error: String(checkpointError),
875
+ documentId: compactHint,
876
+ error: String(compactError),
900
877
  });
901
878
  }
902
879
  }
903
- } catch (error) {
904
- logger.error('Subscription handler error', { collection, error: String(error) });
880
+ }
881
+ catch (error) {
882
+ logger.error("Subscription handler error", { collection, error: String(error) });
905
883
  }
906
884
  };
907
885
 
908
- logger.info('Establishing subscription', {
886
+ logger.info("Establishing subscription", {
909
887
  collection,
910
- checkpoint,
888
+ cursor,
911
889
  limit: 1000,
912
890
  });
913
891
 
914
892
  subscription = convexClient.onUpdate(
915
893
  api.stream,
916
- { checkpoint, limit: 1000 },
894
+ { cursor, limit: 1000 },
917
895
  (response: any) => {
918
- logger.debug('Subscription received update', {
896
+ logger.debug("Subscription received update", {
919
897
  collection,
920
898
  changesCount: response.changes?.length ?? 0,
921
- checkpoint: response.checkpoint,
899
+ cursor: response.cursor,
922
900
  hasMore: response.hasMore,
923
901
  });
924
902
 
925
- // Call async handler directly - no Effect wrapper
926
903
  handleSubscriptionUpdate(response);
927
- }
904
+ },
928
905
  );
929
906
 
930
907
  // Note: markReady() was already called above (local-first)
931
908
  // Subscription is background replication, not blocking
932
- logger.info('Subscription established', { collection });
933
- } catch (error) {
934
- logger.error('Failed to set up collection', { error, collection });
909
+ logger.info("Subscription established", { collection });
910
+ }
911
+ catch (error) {
912
+ logger.error("Failed to set up collection", { error, collection });
935
913
  // Still mark ready on error so UI isn't stuck loading
936
914
  markReady();
937
915
  }
@@ -975,3 +953,58 @@ export function convexCollectionOptions<T extends object>({
975
953
  },
976
954
  };
977
955
  }
956
+
957
+ type LazyCollectionConfig<TSchema extends z.ZodObject<z.ZodRawShape>> = Omit<
958
+ ConvexCollectionConfig<z.infer<TSchema>, TSchema, string>,
959
+ "persistence" | "material"
960
+ >;
961
+
962
+ interface LazyCollection<T extends object> {
963
+ init(material?: Materialized<T>): Promise<void>;
964
+ get(): Collection<T, string, ConvexCollectionUtils<T>, any, T> & NonSingleResult;
965
+ }
966
+
967
+ export type ConvexCollection<T extends object>
968
+ = Collection<T, any, ConvexCollectionUtils<T>, any, T> & NonSingleResult;
969
+
970
+ interface CreateCollectionOptions<TSchema extends z.ZodObject<z.ZodRawShape>> {
971
+ persistence: () => Promise<Persistence>;
972
+ config: () => Omit<LazyCollectionConfig<TSchema>, "material">;
973
+ }
974
+
975
+ export const collection = {
976
+ create<TSchema extends z.ZodObject<z.ZodRawShape>>(
977
+ options: CreateCollectionOptions<TSchema>,
978
+ ): LazyCollection<z.infer<TSchema>> {
979
+ let persistence: Persistence | null = null;
980
+ let resolvedConfig: LazyCollectionConfig<TSchema> | null = null;
981
+ let material: Materialized<z.infer<TSchema>> | undefined;
982
+ type Instance = LazyCollection<z.infer<TSchema>>["get"] extends () => infer R ? R : never;
983
+ let instance: Instance | null = null;
984
+
985
+ return {
986
+ async init(mat?: Materialized<z.infer<TSchema>>) {
987
+ if (!persistence) {
988
+ persistence = await options.persistence();
989
+ resolvedConfig = options.config();
990
+ material = mat;
991
+ }
992
+ },
993
+
994
+ get() {
995
+ if (!persistence || !resolvedConfig) {
996
+ throw new Error("Call init() before get()");
997
+ }
998
+ if (!instance) {
999
+ const opts = convexCollectionOptions({
1000
+ ...resolvedConfig,
1001
+ persistence,
1002
+ material,
1003
+ } as any);
1004
+ instance = createCollection(opts) as any;
1005
+ }
1006
+ return instance!;
1007
+ },
1008
+ };
1009
+ },
1010
+ };