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.
- package/LICENSE +202 -0
- package/README.md +272 -0
- package/dist/client/index.d.ts +183 -0
- package/dist/client/index.js +238 -0
- package/dist/component/_generated/api.d.ts +33 -0
- package/dist/component/_generated/api.js +30 -0
- package/dist/component/_generated/component.d.ts +77 -0
- package/dist/component/_generated/component.js +10 -0
- package/dist/component/_generated/dataModel.d.ts +45 -0
- package/dist/component/_generated/dataModel.js +10 -0
- package/dist/component/_generated/server.d.ts +120 -0
- package/dist/component/_generated/server.js +77 -0
- package/dist/component/convex.config.d.ts +2 -0
- package/dist/component/convex.config.js +2 -0
- package/dist/component/lib.js +272 -0
- package/dist/component/schema.d.ts +28 -0
- package/dist/component/schema.js +17 -0
- package/dist/index.d.ts +3 -0
- package/dist/index.js +3 -0
- package/dist/tiptap/doSync.d.ts +5 -0
- package/dist/tiptap/doSync.js +75 -0
- package/dist/tiptap/getCachedState.d.ts +2 -0
- package/dist/tiptap/getCachedState.js +23 -0
- package/dist/tiptap/index.d.ts +17 -0
- package/dist/tiptap/index.js +134 -0
- package/dist/tiptap/reciveSteps.d.ts +3 -0
- package/dist/tiptap/reciveSteps.js +7 -0
- package/dist/tiptap/syncExtension.d.ts +6 -0
- package/dist/tiptap/syncExtension.js +126 -0
- package/dist/tiptap/types.d.ts +19 -0
- package/dist/tiptap/types.js +1 -0
- package/package.json +81 -0
|
@@ -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
|
+
});
|
package/dist/index.d.ts
ADDED
package/dist/index.js
ADDED
|
@@ -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,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,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;
|