@trestleinc/replicate 1.1.0 → 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.
- package/README.md +446 -260
- package/dist/client/index.d.ts +311 -19
- package/dist/client/index.js +4027 -0
- package/dist/component/_generated/api.d.ts +13 -17
- package/dist/component/_generated/api.js +24 -4
- package/dist/component/_generated/component.d.ts +79 -77
- package/dist/component/_generated/component.js +1 -0
- package/dist/component/_generated/dataModel.d.ts +12 -15
- package/dist/component/_generated/dataModel.js +1 -0
- package/dist/component/_generated/server.d.ts +19 -22
- package/dist/component/_generated/server.js +65 -1
- package/dist/component/_virtual/rolldown_runtime.js +18 -0
- package/dist/component/convex.config.d.ts +6 -2
- package/dist/component/convex.config.js +7 -3
- package/dist/component/logger.d.ts +10 -6
- package/dist/component/logger.js +25 -28
- package/dist/component/public.d.ts +70 -61
- package/dist/component/public.js +311 -295
- package/dist/component/schema.d.ts +53 -45
- package/dist/component/schema.js +26 -32
- package/dist/component/shared/types.d.ts +9 -0
- package/dist/component/shared/types.js +15 -0
- package/dist/server/index.d.ts +134 -13
- package/dist/server/index.js +368 -0
- package/dist/shared/index.d.ts +27 -3
- package/dist/shared/index.js +1 -2
- package/package.json +34 -29
- package/src/client/collection.ts +339 -306
- package/src/client/errors.ts +9 -9
- package/src/client/index.ts +13 -32
- package/src/client/logger.ts +2 -2
- package/src/client/merge.ts +37 -34
- package/src/client/persistence/custom.ts +84 -0
- package/src/client/persistence/index.ts +9 -46
- package/src/client/persistence/indexeddb.ts +111 -84
- package/src/client/persistence/memory.ts +3 -3
- package/src/client/persistence/sqlite/browser.ts +168 -0
- package/src/client/persistence/sqlite/native.ts +29 -0
- package/src/client/persistence/sqlite/schema.ts +124 -0
- package/src/client/persistence/types.ts +32 -28
- package/src/client/prose-schema.ts +55 -0
- package/src/client/prose.ts +28 -25
- package/src/client/replicate.ts +5 -5
- package/src/client/services/cursor.ts +109 -0
- package/src/component/_generated/component.ts +31 -29
- package/src/component/convex.config.ts +2 -2
- package/src/component/logger.ts +7 -7
- package/src/component/public.ts +225 -237
- package/src/component/schema.ts +18 -15
- package/src/server/builder.ts +20 -7
- package/src/server/index.ts +3 -5
- package/src/server/schema.ts +5 -5
- package/src/server/storage.ts +113 -59
- package/src/shared/index.ts +5 -5
- package/src/shared/types.ts +51 -14
- package/dist/client/collection.d.ts +0 -96
- package/dist/client/errors.d.ts +0 -59
- package/dist/client/logger.d.ts +0 -2
- package/dist/client/merge.d.ts +0 -77
- package/dist/client/persistence/adapters/index.d.ts +0 -8
- package/dist/client/persistence/adapters/opsqlite.d.ts +0 -46
- package/dist/client/persistence/adapters/sqljs.d.ts +0 -83
- package/dist/client/persistence/index.d.ts +0 -49
- package/dist/client/persistence/indexeddb.d.ts +0 -17
- package/dist/client/persistence/memory.d.ts +0 -16
- package/dist/client/persistence/sqlite-browser.d.ts +0 -51
- package/dist/client/persistence/sqlite-level.d.ts +0 -63
- package/dist/client/persistence/sqlite-rn.d.ts +0 -36
- package/dist/client/persistence/sqlite.d.ts +0 -47
- package/dist/client/persistence/types.d.ts +0 -42
- package/dist/client/prose.d.ts +0 -56
- package/dist/client/replicate.d.ts +0 -40
- package/dist/client/services/checkpoint.d.ts +0 -18
- package/dist/client/services/reconciliation.d.ts +0 -24
- package/dist/index.js +0 -1620
- package/dist/server/builder.d.ts +0 -94
- package/dist/server/schema.d.ts +0 -27
- package/dist/server/storage.d.ts +0 -80
- package/dist/server.js +0 -281
- package/dist/shared/types.d.ts +0 -50
- package/dist/shared/types.js +0 -6
- package/dist/shared.js +0 -6
- package/src/client/persistence/adapters/index.ts +0 -8
- package/src/client/persistence/adapters/opsqlite.ts +0 -54
- package/src/client/persistence/adapters/sqljs.ts +0 -128
- package/src/client/persistence/sqlite-browser.ts +0 -107
- package/src/client/persistence/sqlite-level.ts +0 -407
- package/src/client/persistence/sqlite-rn.ts +0 -44
- package/src/client/persistence/sqlite.ts +0 -161
- package/src/client/services/checkpoint.ts +0 -86
- package/src/client/services/reconciliation.ts +0 -108
package/src/client/collection.ts
CHANGED
|
@@ -1,18 +1,22 @@
|
|
|
1
|
-
import * as Y from
|
|
2
|
-
import { createMutex } from
|
|
3
|
-
import type { Persistence, PersistenceProvider } from
|
|
4
|
-
import type { ConvexClient } from
|
|
5
|
-
import type
|
|
6
|
-
import
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
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
|
|
25
|
-
import * as prose from
|
|
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 =
|
|
30
|
-
Fragment =
|
|
31
|
-
Server =
|
|
35
|
+
Local = "local",
|
|
36
|
+
Fragment = "fragment",
|
|
37
|
+
Server = "server",
|
|
32
38
|
}
|
|
33
|
-
import type { ProseFields
|
|
39
|
+
import type { ProseFields } from "$/shared/types";
|
|
34
40
|
|
|
35
|
-
const logger = getLogger([
|
|
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:
|
|
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(
|
|
82
|
+
throw new NonRetriableError("Authentication failed");
|
|
77
83
|
}
|
|
78
84
|
if (httpError?.status === 422) {
|
|
79
|
-
throw new NonRetriableError(
|
|
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
|
|
88
|
-
documents:
|
|
89
|
-
|
|
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
|
-
/**
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
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
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
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
|
|
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<
|
|
274
|
-
async prose(documentId: string, field: ProseFields<
|
|
275
|
-
const fieldStr = field
|
|
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
|
-
}
|
|
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
|
-
|
|
385
|
-
|
|
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<
|
|
408
|
+
let ops: BoundReplicateOps<DataType> = null as any;
|
|
391
409
|
|
|
392
410
|
// Create services layer with the persistence KV store
|
|
393
|
-
const
|
|
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
|
|
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(
|
|
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
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
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<
|
|
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
|
|
509
|
-
}
|
|
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<
|
|
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(
|
|
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(
|
|
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(
|
|
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
|
-
}
|
|
554
|
-
|
|
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<
|
|
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
|
-
|
|
583
|
-
_collection: collection,
|
|
584
|
-
_proseFields: proseFields,
|
|
585
|
-
_persistence: persistence,
|
|
554
|
+
schema: schema,
|
|
586
555
|
utils,
|
|
587
556
|
|
|
588
|
-
onInsert: async ({ transaction }: CollectionTransaction<
|
|
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
|
-
}
|
|
606
|
-
|
|
574
|
+
}
|
|
575
|
+
catch (error) {
|
|
576
|
+
handleMutationError(error, "Insert", collection);
|
|
607
577
|
}
|
|
608
578
|
},
|
|
609
579
|
|
|
610
|
-
onUpdate: async ({ transaction }: CollectionTransaction<
|
|
611
|
-
|
|
612
|
-
|
|
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
|
-
|
|
616
|
-
|
|
617
|
-
|
|
618
|
-
|
|
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
|
-
|
|
589
|
+
const metadata = mutation.metadata as { contentSync?: ContentSyncMetadata } | undefined;
|
|
590
|
+
const isContentSync = !!metadata?.contentSync;
|
|
623
591
|
|
|
624
|
-
|
|
625
|
-
|
|
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
|
-
|
|
628
|
-
|
|
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
|
-
|
|
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
|
-
}
|
|
652
|
-
|
|
618
|
+
}
|
|
619
|
+
catch (error) {
|
|
620
|
+
handleMutationError(error, "Update", collection);
|
|
653
621
|
}
|
|
654
622
|
},
|
|
655
623
|
|
|
656
|
-
onDelete: async ({ transaction }: CollectionTransaction<
|
|
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(
|
|
662
|
-
.filter((item): item is
|
|
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
|
-
}
|
|
672
|
-
|
|
640
|
+
}
|
|
641
|
+
catch (error) {
|
|
642
|
+
handleMutationError(error, "Delete", collection);
|
|
673
643
|
}
|
|
674
644
|
},
|
|
675
645
|
|
|
676
646
|
sync: {
|
|
677
|
-
rowUpdateMode:
|
|
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
|
|
662
|
+
const ssrCursor = material?.cursor;
|
|
693
663
|
const ssrCRDTBytes = material?.crdtBytes;
|
|
694
|
-
const docs:
|
|
664
|
+
const docs: DataType[] = ssrDocuments ? [...ssrDocuments] : [];
|
|
695
665
|
|
|
696
666
|
(async () => {
|
|
697
667
|
try {
|
|
698
|
-
ydoc
|
|
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(
|
|
678
|
+
logger.debug("Persistence synced", { collection });
|
|
713
679
|
resolvePersistenceReady?.();
|
|
714
680
|
});
|
|
715
681
|
await persistenceReadyPromise;
|
|
716
|
-
logger.info(
|
|
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<
|
|
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
|
-
|
|
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<
|
|
744
|
-
ops.replace(items);
|
|
745
|
-
logger.info(
|
|
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
|
-
}
|
|
750
|
-
|
|
705
|
+
}
|
|
706
|
+
else {
|
|
751
707
|
ops.replace([]);
|
|
752
|
-
logger.info(
|
|
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(
|
|
763
|
-
|
|
764
|
-
|
|
765
|
-
|
|
766
|
-
|
|
767
|
-
|
|
768
|
-
|
|
769
|
-
|
|
770
|
-
|
|
771
|
-
|
|
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
|
-
|
|
777
|
-
|
|
778
|
-
|
|
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(
|
|
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<
|
|
796
|
-
logger.debug(
|
|
743
|
+
const items = extractItems<DataType>(ymap);
|
|
744
|
+
logger.debug("Snapshot applied", { collection, itemCount: items.length });
|
|
797
745
|
ops.replace(items);
|
|
798
|
-
}
|
|
799
|
-
|
|
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(
|
|
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<
|
|
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(
|
|
774
|
+
logger.debug("Delta applied (no documentId)", { collection });
|
|
826
775
|
return;
|
|
827
776
|
}
|
|
828
777
|
|
|
829
|
-
const itemAfter = extractItem<
|
|
778
|
+
const itemAfter = extractItem<DataType>(ymap, documentId);
|
|
830
779
|
if (itemAfter) {
|
|
831
|
-
logger.debug(
|
|
780
|
+
logger.debug("Upserting item after delta", { collection, documentId });
|
|
832
781
|
ops.upsert([itemAfter]);
|
|
833
|
-
}
|
|
834
|
-
|
|
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
|
-
|
|
840
|
-
|
|
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
|
-
}
|
|
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(
|
|
811
|
+
logger.error("Invalid subscription response", { response });
|
|
861
812
|
return;
|
|
862
813
|
}
|
|
863
814
|
|
|
864
|
-
const { changes,
|
|
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(
|
|
820
|
+
logger.warn("Skipping change with missing crdtBytes", { change });
|
|
871
821
|
continue;
|
|
872
822
|
}
|
|
873
823
|
|
|
874
824
|
try {
|
|
875
|
-
if (operationType ===
|
|
825
|
+
if (operationType === "snapshot") {
|
|
876
826
|
handleSnapshotChange(crdtBytes);
|
|
877
|
-
}
|
|
827
|
+
}
|
|
828
|
+
else {
|
|
878
829
|
handleDeltaChange(crdtBytes, documentId);
|
|
879
830
|
}
|
|
880
|
-
}
|
|
881
|
-
|
|
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
|
-
|
|
891
|
-
|
|
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
|
|
894
|
-
|
|
895
|
-
|
|
896
|
-
|
|
897
|
-
|
|
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
|
-
|
|
875
|
+
documentId: compactHint,
|
|
876
|
+
error: String(compactError),
|
|
900
877
|
});
|
|
901
878
|
}
|
|
902
879
|
}
|
|
903
|
-
}
|
|
904
|
-
|
|
880
|
+
}
|
|
881
|
+
catch (error) {
|
|
882
|
+
logger.error("Subscription handler error", { collection, error: String(error) });
|
|
905
883
|
}
|
|
906
884
|
};
|
|
907
885
|
|
|
908
|
-
logger.info(
|
|
886
|
+
logger.info("Establishing subscription", {
|
|
909
887
|
collection,
|
|
910
|
-
|
|
888
|
+
cursor,
|
|
911
889
|
limit: 1000,
|
|
912
890
|
});
|
|
913
891
|
|
|
914
892
|
subscription = convexClient.onUpdate(
|
|
915
893
|
api.stream,
|
|
916
|
-
{
|
|
894
|
+
{ cursor, limit: 1000 },
|
|
917
895
|
(response: any) => {
|
|
918
|
-
logger.debug(
|
|
896
|
+
logger.debug("Subscription received update", {
|
|
919
897
|
collection,
|
|
920
898
|
changesCount: response.changes?.length ?? 0,
|
|
921
|
-
|
|
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(
|
|
933
|
-
}
|
|
934
|
-
|
|
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
|
+
};
|