cosveti-sync 0.0.1

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.
@@ -0,0 +1,272 @@
1
+ import { v } from 'convex/values';
2
+ import { mutation, query } from './_generated/server.js';
3
+ import { vClientId } from './schema.js';
4
+ import { api } from './_generated/api.js';
5
+ const MAX_DELTA_FETCH = 100;
6
+ const MAX_SNAPSHOT_FETCH = 10;
7
+ export const submitSnapshot = mutation({
8
+ args: {
9
+ id: v.string(),
10
+ version: v.number(),
11
+ content: v.string(),
12
+ pruneSnapshots: v.optional(v.boolean())
13
+ },
14
+ returns: v.null(),
15
+ handler: async (ctx, args) => {
16
+ const existing = await ctx.db
17
+ .query('snapshots')
18
+ .withIndex('id_version', (q) => q.eq('id', args.id).eq('version', args.version))
19
+ .first();
20
+ if (existing) {
21
+ if (existing.content === args.content) {
22
+ return;
23
+ }
24
+ throw new Error(`Snapshot ${args.id} at version ${args.version} already exists ` +
25
+ `with different content: ${existing.content} !== ${args.content}`);
26
+ }
27
+ await ctx.db.insert('snapshots', {
28
+ id: args.id,
29
+ version: args.version,
30
+ content: args.content
31
+ });
32
+ if (args.version > 1 && args.pruneSnapshots) {
33
+ // Delete all older snapshots, except the original one.
34
+ await deleteSnapshotsHelper(ctx, {
35
+ id: args.id,
36
+ afterVersion: 1,
37
+ beforeVersion: args.version
38
+ });
39
+ }
40
+ }
41
+ });
42
+ export const latestVersion = query({
43
+ args: { id: v.string() },
44
+ returns: v.union(v.null(), v.number()),
45
+ handler: async (ctx, args) => {
46
+ const latestDelta = await ctx.db
47
+ .query('deltas')
48
+ .withIndex('id_version', (q) => q.eq('id', args.id))
49
+ .order('desc')
50
+ .first();
51
+ if (latestDelta) {
52
+ return latestDelta.version;
53
+ }
54
+ const latestSnapshot = await ctx.db
55
+ .query('snapshots')
56
+ .withIndex('id_version', (q) => q.eq('id', args.id))
57
+ .order('desc')
58
+ .first();
59
+ return latestSnapshot?.version ?? null;
60
+ }
61
+ });
62
+ export const submitSteps = mutation({
63
+ args: {
64
+ id: v.string(),
65
+ version: v.number(),
66
+ clientId: vClientId,
67
+ steps: v.array(v.string())
68
+ },
69
+ returns: v.union(v.object({
70
+ status: v.literal('needs-rebase'),
71
+ clientIds: v.array(vClientId),
72
+ steps: v.array(v.string())
73
+ }), v.object({ status: v.literal('synced') })),
74
+ handler: async (ctx, args) => {
75
+ const changes = await ctx.db
76
+ .query('deltas')
77
+ .withIndex('id_version', (q) => q.eq('id', args.id).gt('version', args.version))
78
+ .take(MAX_DELTA_FETCH);
79
+ if (changes.length > 0) {
80
+ const [steps, clientIds] = stepsAndClientIds(changes);
81
+ return { status: 'needs-rebase', clientIds, steps };
82
+ }
83
+ await ctx.db.insert('deltas', {
84
+ id: args.id,
85
+ version: args.version + args.steps.length,
86
+ clientId: args.clientId,
87
+ steps: args.steps
88
+ });
89
+ return { status: 'synced' };
90
+ }
91
+ });
92
+ function stepsAndClientIds(deltas) {
93
+ const clientIds = [];
94
+ const steps = [];
95
+ for (const delta of deltas) {
96
+ for (const step of delta.steps) {
97
+ clientIds.push(delta.clientId);
98
+ steps.push(step);
99
+ }
100
+ }
101
+ return [steps, clientIds];
102
+ }
103
+ export const getSnapshot = query({
104
+ args: { id: v.string(), version: v.optional(v.number()) },
105
+ returns: v.union(v.object({
106
+ content: v.null(),
107
+ version: v.null()
108
+ }), v.object({
109
+ content: v.string(),
110
+ version: v.number()
111
+ })),
112
+ handler: async (ctx, args) => {
113
+ const snapshot = await ctx.db
114
+ .query('snapshots')
115
+ .withIndex('id_version', (q) => q.eq('id', args.id).lte('version', args.version ?? Infinity))
116
+ .order('desc')
117
+ .first();
118
+ if (!snapshot) {
119
+ return {
120
+ content: null,
121
+ version: null
122
+ };
123
+ }
124
+ return {
125
+ content: snapshot.content,
126
+ version: snapshot.version
127
+ };
128
+ }
129
+ });
130
+ async function fetchSteps(ctx, id, afterVersion, targetVersion) {
131
+ const deltas = await ctx.db
132
+ .query('deltas')
133
+ .withIndex('id_version', (q) => q
134
+ .eq('id', id)
135
+ .gt('version', afterVersion)
136
+ .lte('version', targetVersion ?? Infinity))
137
+ .take(MAX_DELTA_FETCH);
138
+ if (deltas.length > 0) {
139
+ const firstDelta = deltas[0];
140
+ if (firstDelta.version - firstDelta.steps.length > afterVersion) {
141
+ throw new Error(`Missing steps ${afterVersion + 1}...${firstDelta.version - firstDelta.steps.length}`);
142
+ }
143
+ else if (firstDelta.version - firstDelta.steps.length < afterVersion) {
144
+ firstDelta.steps = firstDelta.steps.slice(afterVersion - (firstDelta.version - firstDelta.steps.length));
145
+ }
146
+ }
147
+ const [steps, clientIds] = stepsAndClientIds(deltas);
148
+ if (deltas.length === MAX_DELTA_FETCH) {
149
+ console.warn(`Max delta fetch reached: ${id} ${afterVersion}...${targetVersion ?? 'end'} stopped at ${deltas[deltas.length - 1].version}`);
150
+ return [steps, clientIds];
151
+ }
152
+ const lastDelta = deltas[deltas.length - 1];
153
+ if (targetVersion && (!lastDelta || lastDelta.version < targetVersion)) {
154
+ const nextDelta = await ctx.db
155
+ .query('deltas')
156
+ .withIndex('id_version', (q) => q.eq('id', id).gt('version', lastDelta.version))
157
+ .first();
158
+ if (!nextDelta) {
159
+ throw new Error(`Missing steps ${lastDelta ? lastDelta.version + 1 : afterVersion}...${targetVersion}`);
160
+ }
161
+ for (let i = 0; i < targetVersion - lastDelta.version; i++) {
162
+ steps.push(nextDelta.steps[i]);
163
+ clientIds.push(nextDelta.clientId);
164
+ }
165
+ }
166
+ if (targetVersion && steps.length !== targetVersion - afterVersion) {
167
+ throw new Error(`Steps mismatch ${afterVersion}...${targetVersion}: ${steps.length}`);
168
+ }
169
+ return [steps, clientIds];
170
+ }
171
+ export const getSteps = query({
172
+ args: { id: v.string(), version: v.number() },
173
+ returns: v.object({
174
+ steps: v.array(v.string()),
175
+ clientIds: v.array(vClientId),
176
+ version: v.number()
177
+ }),
178
+ handler: async (ctx, args) => {
179
+ const [steps, clientIds] = await fetchSteps(ctx, args.id, args.version);
180
+ return { steps, clientIds, version: args.version + steps.length };
181
+ }
182
+ });
183
+ /**
184
+ * Delete snapshots in the given range, not including the bounds.
185
+ * To clean up old snapshots, call this with the current version as the
186
+ * beforeVersion and the first version (1) as the afterVersion.
187
+ */
188
+ export const deleteSnapshots = mutation({
189
+ args: {
190
+ id: v.string(),
191
+ afterVersion: v.optional(v.number()),
192
+ beforeVersion: v.optional(v.number())
193
+ },
194
+ returns: v.null(),
195
+ handler: async (ctx, args) => {
196
+ await deleteSnapshotsHelper(ctx, args);
197
+ }
198
+ });
199
+ async function deleteSnapshotsHelper(ctx, args) {
200
+ const versions = await ctx.db
201
+ .query('snapshots')
202
+ .withIndex('id_version', (q) => {
203
+ const eq = q.eq('id', args.id);
204
+ const after = args.afterVersion !== undefined ? eq.gt('version', args.afterVersion) : eq;
205
+ const before = args.beforeVersion !== undefined ? after.lt('version', args.beforeVersion) : after;
206
+ return before;
207
+ })
208
+ .take(MAX_SNAPSHOT_FETCH);
209
+ await Promise.all(versions.map((doc) => ctx.db.delete(doc._id)));
210
+ if (versions.length === MAX_SNAPSHOT_FETCH) {
211
+ await ctx.scheduler.runAfter(0, api.lib.deleteSnapshots, {
212
+ id: args.id,
213
+ beforeVersion: args.beforeVersion,
214
+ afterVersion: versions[versions.length - 1].version
215
+ });
216
+ }
217
+ }
218
+ /**
219
+ * Delete steps before some timestamp.
220
+ * To clean up old steps, call this with a date in the past for beforeTs.
221
+ * By default it will ensure that all steps are older than the latest snapshot.
222
+ */
223
+ export const deleteSteps = mutation({
224
+ args: {
225
+ id: v.string(),
226
+ afterVersion: v.optional(v.number()),
227
+ beforeTs: v.number(),
228
+ deleteNewerThanLatestSnapshot: v.optional(v.boolean())
229
+ },
230
+ returns: v.null(),
231
+ handler: async (ctx, args) => {
232
+ let beforeTs = args.beforeTs;
233
+ if (!args.deleteNewerThanLatestSnapshot) {
234
+ const latestSnapshot = await ctx.db
235
+ .query('snapshots')
236
+ .withIndex('id_version', (q) => q.eq('id', args.id))
237
+ .order('desc')
238
+ .first();
239
+ if (latestSnapshot) {
240
+ beforeTs = Math.min(beforeTs, latestSnapshot._creationTime);
241
+ }
242
+ }
243
+ const deltas = (await ctx.db
244
+ .query('deltas')
245
+ .withIndex('id_version', (q) => q.eq('id', args.id).gt('version', args.afterVersion ?? -Infinity))
246
+ .take(MAX_DELTA_FETCH)).filter((doc) => doc._creationTime < beforeTs);
247
+ await Promise.all(deltas.map((doc) => ctx.db.delete(doc._id)));
248
+ if (deltas.length === MAX_DELTA_FETCH) {
249
+ await ctx.scheduler.runAfter(0, api.lib.deleteSteps, {
250
+ id: args.id,
251
+ beforeTs,
252
+ // We already checked that the timestamp is before
253
+ deleteNewerThanLatestSnapshot: true
254
+ });
255
+ }
256
+ }
257
+ });
258
+ /**
259
+ * Delete a document and all its snapshots & steps.
260
+ */
261
+ export const deleteDocument = mutation({
262
+ args: { id: v.string() },
263
+ returns: v.null(),
264
+ handler: async (ctx, args) => {
265
+ await ctx.runMutation(api.lib.deleteSnapshots, { id: args.id });
266
+ await ctx.scheduler.runAfter(0, api.lib.deleteSteps, {
267
+ id: args.id,
268
+ beforeTs: Infinity,
269
+ deleteNewerThanLatestSnapshot: true
270
+ });
271
+ }
272
+ });
@@ -0,0 +1,28 @@
1
+ export declare const vClientId: import("convex/values").VUnion<string | number, [import("convex/values").VString<string, "required">, import("convex/values").VFloat64<number, "required">], "required", never>;
2
+ declare const _default: import("convex/server").SchemaDefinition<{
3
+ snapshots: import("convex/server").TableDefinition<import("convex/values").VObject<{
4
+ id: string;
5
+ version: number;
6
+ content: string;
7
+ }, {
8
+ id: import("convex/values").VString<string, "required">;
9
+ version: import("convex/values").VFloat64<number, "required">;
10
+ content: import("convex/values").VString<string, "required">;
11
+ }, "required", "id" | "version" | "content">, {
12
+ id_version: ["id", "version", "_creationTime"];
13
+ }, {}, {}>;
14
+ deltas: import("convex/server").TableDefinition<import("convex/values").VObject<{
15
+ id: string;
16
+ version: number;
17
+ clientId: string | number;
18
+ steps: string[];
19
+ }, {
20
+ id: import("convex/values").VString<string, "required">;
21
+ version: import("convex/values").VFloat64<number, "required">;
22
+ clientId: import("convex/values").VUnion<string | number, [import("convex/values").VString<string, "required">, import("convex/values").VFloat64<number, "required">], "required", never>;
23
+ steps: import("convex/values").VArray<string[], import("convex/values").VString<string, "required">, "required">;
24
+ }, "required", "id" | "version" | "clientId" | "steps">, {
25
+ id_version: ["id", "version", "_creationTime"];
26
+ }, {}, {}>;
27
+ }, true>;
28
+ export default _default;
@@ -0,0 +1,17 @@
1
+ import { defineSchema, defineTable } from 'convex/server';
2
+ import { v } from 'convex/values';
3
+ export const vClientId = v.union(v.string(), v.number());
4
+ export default defineSchema({
5
+ snapshots: defineTable({
6
+ id: v.string(),
7
+ version: v.number(),
8
+ content: v.string()
9
+ }).index('id_version', ['id', 'version']),
10
+ deltas: defineTable({
11
+ id: v.string(),
12
+ // The version of the last step.
13
+ version: v.number(),
14
+ clientId: vClientId,
15
+ steps: v.array(v.string())
16
+ }).index('id_version', ['id', 'version'])
17
+ });
@@ -0,0 +1,3 @@
1
+ export { useTiptapSync } from './tiptap/index.js';
2
+ export type { UseSyncOptions, InitialState, SyncContext } from './tiptap/types.js';
3
+ export type { SyncApi } from './client/index.js';
package/dist/index.js ADDED
@@ -0,0 +1,3 @@
1
+ // Reexport your entry components here
2
+ // Main entry point for @feavel/svelte-convex-tiptap-sync package
3
+ export { useTiptapSync } from './tiptap/index.js';
@@ -0,0 +1,5 @@
1
+ import { type Editor } from '@tiptap/core';
2
+ import type { SyncApi } from '../client/index.js';
3
+ import type { InitialState } from './types.js';
4
+ import type { ConvexClient } from 'convex/browser';
5
+ export declare function doSync(editor: Editor, convex: ConvexClient, syncApi: SyncApi, id: string, serverVersion: number | null, initialState: InitialState, debug?: boolean): Promise<boolean>;
@@ -0,0 +1,75 @@
1
+ import {} from '@tiptap/core';
2
+ import * as collab from '@tiptap/pm/collab';
3
+ import { Step } from '@tiptap/pm/transform';
4
+ import { receiveSteps } from './reciveSteps.js';
5
+ import { MAX_STEPS_SYNC } from './index.js';
6
+ export async function doSync(editor, convex, syncApi, id, serverVersion, initialState, debug) {
7
+ const log = debug ? console.debug : () => { };
8
+ if (serverVersion === null) {
9
+ if (initialState.initialVersion != undefined && initialState.initialVersion <= 1) {
10
+ // This is a new document, so we can create it on the server.
11
+ // Note: this should only happen if the initial version is loaded from
12
+ // a local cache. Creating a new document on the client will set the
13
+ // initial version to 1 optimistically.
14
+ log('Syncing new document', { id });
15
+ await convex.mutation(syncApi.submitSnapshot, {
16
+ id,
17
+ version: initialState.initialVersion,
18
+ content: JSON.stringify(initialState.initialContent)
19
+ });
20
+ }
21
+ else {
22
+ // TODO: Handle deletion gracefully
23
+ throw new Error("Syncing a document that doesn't exist server-side");
24
+ }
25
+ }
26
+ const version = collab.getVersion(editor.state);
27
+ if (serverVersion !== null && serverVersion > version) {
28
+ log('Updating to server version', {
29
+ id,
30
+ version,
31
+ serverVersion
32
+ });
33
+ const steps = await convex.query(syncApi.getSteps, {
34
+ id,
35
+ version
36
+ });
37
+ receiveSteps(editor, steps.steps.map((step) => Step.fromJSON(editor.schema, JSON.parse(step))), steps.clientIds);
38
+ }
39
+ let anyChanges = false;
40
+ while (true) {
41
+ const sendable = collab.sendableSteps(editor.state);
42
+ if (!sendable) {
43
+ break;
44
+ }
45
+ const steps = sendable.steps
46
+ .slice(0, MAX_STEPS_SYNC)
47
+ .map((step) => JSON.stringify(step.toJSON()));
48
+ log('Sending steps', { steps, version: sendable.version });
49
+ const result = await convex.mutation(syncApi.submitSteps, {
50
+ id,
51
+ steps,
52
+ version: sendable.version,
53
+ clientId: sendable.clientID
54
+ });
55
+ if (result.status === 'synced') {
56
+ anyChanges = true;
57
+ // We replay the steps locally to avoid refetching them.
58
+ receiveSteps(editor, steps.map((step) => Step.fromJSON(editor.schema, JSON.parse(step))), steps.map(() => sendable.clientID));
59
+ log('Synced', {
60
+ steps,
61
+ version,
62
+ newVersion: collab.getVersion(editor.state)
63
+ });
64
+ continue;
65
+ }
66
+ if (result.status === 'needs-rebase') {
67
+ receiveSteps(editor, result.steps.map((step) => Step.fromJSON(editor.schema, JSON.parse(step))), result.clientIds);
68
+ log('Rebased', {
69
+ steps,
70
+ newVersion: collab.getVersion(editor.state)
71
+ });
72
+ }
73
+ }
74
+ return anyChanges;
75
+ }
@@ -0,0 +1,2 @@
1
+ import type { InitialState } from './types.js';
2
+ export declare function getCachedState(id: string, cacheKeyPrefix?: string): InitialState | undefined;
@@ -0,0 +1,23 @@
1
+ export function getCachedState(id, cacheKeyPrefix) {
2
+ // Check if we're in a browser environment before accessing sessionStorage
3
+ if (typeof window === 'undefined' || !window.sessionStorage) {
4
+ return undefined;
5
+ }
6
+ const cacheKey = `${cacheKeyPrefix ?? 'convex-sync'}-${id}`;
7
+ const cache = localStorage.getItem(cacheKey);
8
+ if (cache) {
9
+ try {
10
+ const { content, version, steps } = JSON.parse(cache);
11
+ return {
12
+ initialContent: content,
13
+ initialVersion: Number(version),
14
+ restoredSteps: (steps ?? [])
15
+ };
16
+ }
17
+ catch (e) {
18
+ console.warn('Failed to parse cached state', e);
19
+ return undefined;
20
+ }
21
+ }
22
+ return undefined;
23
+ }
@@ -0,0 +1,17 @@
1
+ import type { InitialState, UseSyncOptions } from './types.js';
2
+ import { type Writable } from 'svelte/store';
3
+ import type { SyncApi } from '../client/index.js';
4
+ import type { JSONContent } from '@tiptap/core';
5
+ import type { ConvexClient } from 'convex/browser';
6
+ export declare const MAX_STEPS_SYNC = 10;
7
+ export declare const SNAPSHOT_DEBOUNCE_MS = 1000;
8
+ export declare function useTiptapSync(convex: ConvexClient, syncApi: SyncApi, id: string, opts?: UseSyncOptions): {
9
+ isSyncEnabled: Writable<boolean>;
10
+ isLoading: import("svelte/store").Readable<boolean>;
11
+ initialContent: import("svelte/store").Readable<string | JSONContent | JSONContent[] | null>;
12
+ extension: import("svelte/store").Readable<import("@tiptap/core").AnyExtension | null>;
13
+ create: (content: JSONContent) => Promise<void>;
14
+ };
15
+ export declare function createInitialStateStore(convex: ConvexClient, syncApi: SyncApi, id: string, cacheKeyPrefix?: string): Writable<InitialState & {
16
+ loading: boolean;
17
+ }>;
@@ -0,0 +1,134 @@
1
+ import { getCachedState } from './getCachedState.js';
2
+ import { derived, writable } from 'svelte/store';
3
+ import { syncExtension } from './syncExtension.js';
4
+ // import { useConvexClient } from 'convex-svelte';
5
+ export const MAX_STEPS_SYNC = 10;
6
+ export const SNAPSHOT_DEBOUNCE_MS = 1000;
7
+ export function useTiptapSync(convex, syncApi, id, opts) {
8
+ // console.log('Use tiptap sync with document id: ' + id);
9
+ // const convex = useConvexClient();
10
+ const initialStateStore = createInitialStateStore(convex, syncApi, id);
11
+ const isSyncEnabled = writable(true);
12
+ const optimisticUpdate = (localQueryStore, args) => {
13
+ const existing = localQueryStore.getQuery(syncApi.getSnapshot, { id: args.id });
14
+ console.log('Existing content:' + existing);
15
+ if (!existing?.content) {
16
+ localQueryStore.setQuery(syncApi.getSnapshot, { id: args.id }, {
17
+ version: args.version,
18
+ content: args.content
19
+ });
20
+ }
21
+ const version = localQueryStore.getQuery(syncApi.latestVersion, { id: args.id });
22
+ if (version === null) {
23
+ localQueryStore.setQuery(syncApi.latestVersion, { id: args.id }, args.version);
24
+ }
25
+ };
26
+ // Submit snapshot mutation with optimistic update
27
+ const submitSnapshot = (args) => convex.mutation(syncApi.submitSnapshot, args, { optimisticUpdate });
28
+ // Create function (stable reference - no useCallback needed)
29
+ const create = async (content) => {
30
+ console.log('Creating new document', { id });
31
+ await submitSnapshot({
32
+ id,
33
+ version: 1,
34
+ content: JSON.stringify(content)
35
+ });
36
+ };
37
+ const state = derived([initialStateStore], ([$initialState]) => {
38
+ if ($initialState.loading) {
39
+ // console.log('$initialState.loading');
40
+ return {
41
+ isLoading: true,
42
+ initialContent: null,
43
+ extension: null
44
+ };
45
+ }
46
+ if (!$initialState.initialContent) {
47
+ console.log('No content found for this document yet');
48
+ return {
49
+ isLoading: false,
50
+ initialContent: null,
51
+ extension: null
52
+ };
53
+ }
54
+ const extension = syncExtension(convex, id, syncApi, $initialState, opts, isSyncEnabled);
55
+ return {
56
+ isLoading: false,
57
+ initialContent: $initialState.initialContent,
58
+ extension
59
+ };
60
+ });
61
+ const isLoading = derived(state, ($s) => $s.isLoading);
62
+ const initialContent = derived(state, ($s) => $s.initialContent);
63
+ const extension = derived(state, ($s) => $s.extension);
64
+ return { isSyncEnabled, isLoading, initialContent, extension, create };
65
+ }
66
+ export function createInitialStateStore(convex, syncApi, id, cacheKeyPrefix) {
67
+ // const convex = useConvexClient();
68
+ const store = writable({
69
+ loading: true,
70
+ initialContent: null
71
+ });
72
+ // Try cached state first (will return immediately)
73
+ const cachedState = getCachedState(id, cacheKeyPrefix);
74
+ if (cachedState) {
75
+ console.log('Loaded from cache!');
76
+ store.set({
77
+ loading: false,
78
+ ...cachedState
79
+ });
80
+ }
81
+ // Currently unsupproted because convex.onUpdate doesn't have same 'skip' parameter as convex-svelte useQuery
82
+ // So the store will be set first, then a newer version from server will arrive
83
+ // const queryArgs = cachedState ? 'skip' : { id };
84
+ const queryArgs = { id };
85
+ convex
86
+ .query(syncApi.getSnapshot, queryArgs)
87
+ .then((result) => {
88
+ // Handle missing document case
89
+ if (!result) {
90
+ store.set({ loading: false, initialContent: null });
91
+ return;
92
+ }
93
+ const { content, version } = result;
94
+ // Handle explicit null content
95
+ if (content === null) {
96
+ store.set({ loading: false, initialContent: null });
97
+ return;
98
+ }
99
+ // Validate content type before parsing
100
+ if (typeof content !== 'string') {
101
+ const errorMsg = `Invalid content type received: ${typeof content}`;
102
+ console.error(errorMsg, { content, version });
103
+ throw new Error(errorMsg); // Propagate to catch block for unified error handling
104
+ }
105
+ try {
106
+ // Safely parse and validate structure
107
+ const parsedContent = JSON.parse(content);
108
+ // Optional: Add runtime type validation here if Content has specific structure
109
+ // if (!isValidContent(parsedContent)) throw new Error('Invalid content structure');
110
+ store.set({
111
+ loading: false,
112
+ initialContent: parsedContent, // Assert only after validation
113
+ initialVersion: version
114
+ });
115
+ }
116
+ catch (parseError) {
117
+ console.error('Content parsing failed:', parseError, { rawContent: content });
118
+ throw new Error(`JSON parse error: ${parseError instanceof Error ? parseError.message : 'Unknown error'}`);
119
+ }
120
+ })
121
+ .catch((error) => {
122
+ console.error('Snapshot load failed:', error);
123
+ // Unified error state update (covers query errors, type errors, parse errors)
124
+ store.set({
125
+ loading: false,
126
+ initialContent: null
127
+ // Optional: Add error tracking if your store supports it
128
+ // error: error instanceof Error ? error.message : 'Unknown snapshot error'
129
+ });
130
+ // Re-throw if caller needs to handle errors (remove if errors should be fully absorbed)
131
+ // throw error;
132
+ });
133
+ return store;
134
+ }
@@ -0,0 +1,3 @@
1
+ import { Step } from '@tiptap/pm/transform';
2
+ import type { Editor } from '@tiptap/core';
3
+ export declare function receiveSteps(editor: Editor, steps: Step[], clientIds: (string | number)[]): void;
@@ -0,0 +1,7 @@
1
+ import { Step } from '@tiptap/pm/transform';
2
+ import * as collab from '@tiptap/pm/collab';
3
+ export function receiveSteps(editor, steps, clientIds) {
4
+ editor.view.dispatch(collab.receiveTransaction(editor.state, steps, clientIds, {
5
+ mapSelectionBackward: true
6
+ }));
7
+ }
@@ -0,0 +1,6 @@
1
+ import { type AnyExtension } from '@tiptap/core';
2
+ import type { SyncApi } from '../client/index.js';
3
+ import type { InitialState, UseSyncOptions } from './types.js';
4
+ import type { ConvexClient } from 'convex/browser';
5
+ import { type Writable } from 'svelte/store';
6
+ export declare function syncExtension(convex: ConvexClient, id: string, syncApi: SyncApi, initialState: InitialState, opts?: UseSyncOptions, isSyncEnabled?: Writable<boolean>): AnyExtension;