@eide/sync-client 0.1.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/dist/index.d.ts +542 -0
- package/dist/index.js +1572 -0
- package/dist/react.d.ts +541 -0
- package/dist/react.js +1857 -0
- package/package.json +67 -0
package/dist/index.js
ADDED
|
@@ -0,0 +1,1572 @@
|
|
|
1
|
+
// src/core/offline-queue.ts
|
|
2
|
+
var queueIdCounter = 0;
|
|
3
|
+
function generateQueueId() {
|
|
4
|
+
return `q_${Date.now()}_${++queueIdCounter}`;
|
|
5
|
+
}
|
|
6
|
+
var OfflineQueue = class {
|
|
7
|
+
constructor(storage) {
|
|
8
|
+
this.storeName = "__pending_mutations";
|
|
9
|
+
this.storage = storage;
|
|
10
|
+
}
|
|
11
|
+
/**
|
|
12
|
+
* Enqueue a new mutation. Deduplicates by clientId —
|
|
13
|
+
* if a pending mutation already exists for the same clientId with the same op,
|
|
14
|
+
* the newer one replaces it (for updates).
|
|
15
|
+
*/
|
|
16
|
+
async enqueue(mutation) {
|
|
17
|
+
const pending = {
|
|
18
|
+
...mutation,
|
|
19
|
+
queueId: generateQueueId(),
|
|
20
|
+
createdAt: (/* @__PURE__ */ new Date()).toISOString(),
|
|
21
|
+
retryCount: 0
|
|
22
|
+
};
|
|
23
|
+
if (mutation.op === "update") {
|
|
24
|
+
const existing = await this.getAll();
|
|
25
|
+
const duplicate = existing.find(
|
|
26
|
+
(m) => m.clientId === mutation.clientId && m.op === "update"
|
|
27
|
+
);
|
|
28
|
+
if (duplicate) {
|
|
29
|
+
await this.remove(duplicate.queueId);
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
await this.storage.put(this.storeName, pending.queueId, pending);
|
|
33
|
+
return pending;
|
|
34
|
+
}
|
|
35
|
+
/**
|
|
36
|
+
* Get all pending mutations in creation order.
|
|
37
|
+
*/
|
|
38
|
+
async getAll() {
|
|
39
|
+
const all = await this.storage.getAll(this.storeName);
|
|
40
|
+
return all.sort((a, b) => a.createdAt.localeCompare(b.createdAt));
|
|
41
|
+
}
|
|
42
|
+
/**
|
|
43
|
+
* Get a batch of mutations for pushing.
|
|
44
|
+
*/
|
|
45
|
+
async getBatch(limit) {
|
|
46
|
+
const all = await this.getAll();
|
|
47
|
+
return all.slice(0, limit);
|
|
48
|
+
}
|
|
49
|
+
/**
|
|
50
|
+
* Remove a mutation after successful push.
|
|
51
|
+
*/
|
|
52
|
+
async remove(queueId) {
|
|
53
|
+
await this.storage.delete(this.storeName, queueId);
|
|
54
|
+
}
|
|
55
|
+
/**
|
|
56
|
+
* Remove multiple mutations.
|
|
57
|
+
*/
|
|
58
|
+
async removeBatch(queueIds) {
|
|
59
|
+
for (const id of queueIds) {
|
|
60
|
+
await this.storage.delete(this.storeName, id);
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
/**
|
|
64
|
+
* Increment retry count for a failed mutation.
|
|
65
|
+
*/
|
|
66
|
+
async incrementRetry(queueId) {
|
|
67
|
+
const mutation = await this.storage.get(
|
|
68
|
+
this.storeName,
|
|
69
|
+
queueId
|
|
70
|
+
);
|
|
71
|
+
if (mutation) {
|
|
72
|
+
mutation.retryCount++;
|
|
73
|
+
await this.storage.put(this.storeName, queueId, mutation);
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
/**
|
|
77
|
+
* Get count of pending mutations.
|
|
78
|
+
*/
|
|
79
|
+
async count() {
|
|
80
|
+
const all = await this.getAll();
|
|
81
|
+
return all.length;
|
|
82
|
+
}
|
|
83
|
+
/**
|
|
84
|
+
* Clear all pending mutations.
|
|
85
|
+
*/
|
|
86
|
+
async clear() {
|
|
87
|
+
await this.storage.clear(this.storeName);
|
|
88
|
+
}
|
|
89
|
+
};
|
|
90
|
+
|
|
91
|
+
// src/core/conflict-resolver.ts
|
|
92
|
+
function resolveConflict(local, server, strategy, customResolver) {
|
|
93
|
+
switch (strategy) {
|
|
94
|
+
case "server-wins":
|
|
95
|
+
return serverWins(local, server);
|
|
96
|
+
case "client-wins":
|
|
97
|
+
return clientWins(local, server);
|
|
98
|
+
case "field-lww":
|
|
99
|
+
return fieldLWW(local, server);
|
|
100
|
+
case "custom":
|
|
101
|
+
if (!customResolver) {
|
|
102
|
+
return serverWins(local, server);
|
|
103
|
+
}
|
|
104
|
+
return customResolver(local, server);
|
|
105
|
+
default:
|
|
106
|
+
return serverWins(local, server);
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
function serverWins(local, server) {
|
|
110
|
+
const conflictedFields = findDifferentFields(local, server);
|
|
111
|
+
return { resolved: { ...server }, conflictedFields };
|
|
112
|
+
}
|
|
113
|
+
function clientWins(local, server) {
|
|
114
|
+
const conflictedFields = findDifferentFields(local, server);
|
|
115
|
+
return { resolved: { ...local }, conflictedFields };
|
|
116
|
+
}
|
|
117
|
+
function fieldLWW(local, server) {
|
|
118
|
+
const allKeys = /* @__PURE__ */ new Set([...Object.keys(local), ...Object.keys(server)]);
|
|
119
|
+
const resolved = {};
|
|
120
|
+
const conflictedFields = [];
|
|
121
|
+
for (const key of allKeys) {
|
|
122
|
+
const localVal = local[key];
|
|
123
|
+
const serverVal = server[key];
|
|
124
|
+
if (deepEqual(localVal, serverVal)) {
|
|
125
|
+
resolved[key] = serverVal;
|
|
126
|
+
} else if (key in local && key in server) {
|
|
127
|
+
resolved[key] = serverVal;
|
|
128
|
+
conflictedFields.push(key);
|
|
129
|
+
} else if (key in local) {
|
|
130
|
+
resolved[key] = localVal;
|
|
131
|
+
} else {
|
|
132
|
+
resolved[key] = serverVal;
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
return { resolved, conflictedFields };
|
|
136
|
+
}
|
|
137
|
+
function findDifferentFields(local, server) {
|
|
138
|
+
const allKeys = /* @__PURE__ */ new Set([...Object.keys(local), ...Object.keys(server)]);
|
|
139
|
+
const different = [];
|
|
140
|
+
for (const key of allKeys) {
|
|
141
|
+
if (!deepEqual(local[key], server[key])) {
|
|
142
|
+
different.push(key);
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
return different;
|
|
146
|
+
}
|
|
147
|
+
function deepEqual(a, b) {
|
|
148
|
+
if (a === b) return true;
|
|
149
|
+
if (a == null || b == null) return a === b;
|
|
150
|
+
if (typeof a !== typeof b) return false;
|
|
151
|
+
if (Array.isArray(a)) {
|
|
152
|
+
if (!Array.isArray(b) || a.length !== b.length) return false;
|
|
153
|
+
return a.every((item, i) => deepEqual(item, b[i]));
|
|
154
|
+
}
|
|
155
|
+
if (typeof a === "object") {
|
|
156
|
+
const aObj = a;
|
|
157
|
+
const bObj = b;
|
|
158
|
+
const aKeys = Object.keys(aObj);
|
|
159
|
+
const bKeys = Object.keys(bObj);
|
|
160
|
+
if (aKeys.length !== bKeys.length) return false;
|
|
161
|
+
return aKeys.every((key) => deepEqual(aObj[key], bObj[key]));
|
|
162
|
+
}
|
|
163
|
+
return false;
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
// src/core/sync-engine.ts
|
|
167
|
+
var SYNC_PULL_QUERY = `
|
|
168
|
+
query SyncPull($modelKey: String!, $since: String!, $limit: Int) {
|
|
169
|
+
syncPull(modelKey: $modelKey, since: $since, limit: $limit) {
|
|
170
|
+
items {
|
|
171
|
+
id
|
|
172
|
+
modelKey
|
|
173
|
+
naturalKey
|
|
174
|
+
data
|
|
175
|
+
metadata
|
|
176
|
+
syncVersion
|
|
177
|
+
updatedAt
|
|
178
|
+
deleted
|
|
179
|
+
}
|
|
180
|
+
cursor
|
|
181
|
+
hasMore
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
`;
|
|
185
|
+
var SYNC_PUSH_MUTATION = `
|
|
186
|
+
mutation SyncPush($items: [SyncPushItemInput!]!) {
|
|
187
|
+
syncPush(items: $items) {
|
|
188
|
+
items {
|
|
189
|
+
clientId
|
|
190
|
+
serverId
|
|
191
|
+
syncVersion
|
|
192
|
+
status
|
|
193
|
+
serverData
|
|
194
|
+
serverSyncVersion
|
|
195
|
+
error
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
`;
|
|
200
|
+
var RECORD_CHANGED_SUBSCRIPTION = `
|
|
201
|
+
subscription RecordChanged($modelKey: String!) {
|
|
202
|
+
recordChanged(modelKey: $modelKey) {
|
|
203
|
+
type
|
|
204
|
+
recordId
|
|
205
|
+
modelKey
|
|
206
|
+
naturalKey
|
|
207
|
+
syncVersion
|
|
208
|
+
data
|
|
209
|
+
updatedBy
|
|
210
|
+
timestamp
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
`;
|
|
214
|
+
var clientIdCounter = 0;
|
|
215
|
+
function generateClientId() {
|
|
216
|
+
return `c_${Date.now()}_${++clientIdCounter}`;
|
|
217
|
+
}
|
|
218
|
+
var SyncEngine = class {
|
|
219
|
+
constructor(config, storage) {
|
|
220
|
+
this.listeners = [];
|
|
221
|
+
this.syncTimer = null;
|
|
222
|
+
this.wsCleanup = null;
|
|
223
|
+
this.running = false;
|
|
224
|
+
this.syncing = false;
|
|
225
|
+
this.config = {
|
|
226
|
+
...config,
|
|
227
|
+
pollInterval: config.pollInterval ?? 5e3,
|
|
228
|
+
pullLimit: config.pullLimit ?? 100,
|
|
229
|
+
pushBatchSize: config.pushBatchSize ?? 50,
|
|
230
|
+
conflictStrategy: config.conflictStrategy ?? "server-wins",
|
|
231
|
+
debug: config.debug ?? false
|
|
232
|
+
};
|
|
233
|
+
this.storage = storage;
|
|
234
|
+
this.queue = new OfflineQueue(storage);
|
|
235
|
+
}
|
|
236
|
+
// -------------------------------------------------------------------------
|
|
237
|
+
// Event System
|
|
238
|
+
// -------------------------------------------------------------------------
|
|
239
|
+
on(handler) {
|
|
240
|
+
this.listeners.push(handler);
|
|
241
|
+
return () => {
|
|
242
|
+
this.listeners = this.listeners.filter((h) => h !== handler);
|
|
243
|
+
};
|
|
244
|
+
}
|
|
245
|
+
emit(event) {
|
|
246
|
+
for (const handler of this.listeners) {
|
|
247
|
+
try {
|
|
248
|
+
handler(event);
|
|
249
|
+
} catch {
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
log(...args) {
|
|
254
|
+
if (this.config.debug) {
|
|
255
|
+
console.log("[SyncEngine]", ...args);
|
|
256
|
+
}
|
|
257
|
+
}
|
|
258
|
+
// -------------------------------------------------------------------------
|
|
259
|
+
// Lifecycle
|
|
260
|
+
// -------------------------------------------------------------------------
|
|
261
|
+
/**
|
|
262
|
+
* Start background sync loop and subscriptions.
|
|
263
|
+
*/
|
|
264
|
+
start() {
|
|
265
|
+
if (this.running) return;
|
|
266
|
+
this.running = true;
|
|
267
|
+
this.log("Starting sync engine");
|
|
268
|
+
void this.sync();
|
|
269
|
+
this.syncTimer = setInterval(() => {
|
|
270
|
+
if (!this.syncing) {
|
|
271
|
+
void this.sync();
|
|
272
|
+
}
|
|
273
|
+
}, this.config.pollInterval);
|
|
274
|
+
this.connectSubscriptions();
|
|
275
|
+
}
|
|
276
|
+
/**
|
|
277
|
+
* Stop sync engine, close connections.
|
|
278
|
+
*/
|
|
279
|
+
stop() {
|
|
280
|
+
this.running = false;
|
|
281
|
+
this.log("Stopping sync engine");
|
|
282
|
+
if (this.syncTimer) {
|
|
283
|
+
clearInterval(this.syncTimer);
|
|
284
|
+
this.syncTimer = null;
|
|
285
|
+
}
|
|
286
|
+
if (this.wsCleanup) {
|
|
287
|
+
this.wsCleanup();
|
|
288
|
+
this.wsCleanup = null;
|
|
289
|
+
}
|
|
290
|
+
this.emit({ type: "disconnected" });
|
|
291
|
+
}
|
|
292
|
+
/**
|
|
293
|
+
* Force an immediate sync cycle.
|
|
294
|
+
*/
|
|
295
|
+
async sync() {
|
|
296
|
+
if (this.syncing) {
|
|
297
|
+
return { pulled: 0, pushed: 0, conflicts: 0, errors: 0 };
|
|
298
|
+
}
|
|
299
|
+
this.syncing = true;
|
|
300
|
+
this.emit({ type: "sync-start" });
|
|
301
|
+
const result = {
|
|
302
|
+
pulled: 0,
|
|
303
|
+
pushed: 0,
|
|
304
|
+
conflicts: 0,
|
|
305
|
+
errors: 0
|
|
306
|
+
};
|
|
307
|
+
try {
|
|
308
|
+
const pushResult = await this.pushPending();
|
|
309
|
+
result.pushed = pushResult.pushed;
|
|
310
|
+
result.conflicts = pushResult.conflicts;
|
|
311
|
+
result.errors = pushResult.errors;
|
|
312
|
+
for (const modelKey of this.config.modelKeys) {
|
|
313
|
+
const pullResult = await this.pullModel(modelKey);
|
|
314
|
+
result.pulled += pullResult;
|
|
315
|
+
}
|
|
316
|
+
this.emit({ type: "sync-complete", result });
|
|
317
|
+
this.log("Sync complete:", result);
|
|
318
|
+
} catch (error) {
|
|
319
|
+
const err = error instanceof Error ? error : new Error(String(error));
|
|
320
|
+
this.emit({ type: "sync-error", error: err });
|
|
321
|
+
this.log("Sync error:", err.message);
|
|
322
|
+
} finally {
|
|
323
|
+
this.syncing = false;
|
|
324
|
+
}
|
|
325
|
+
return result;
|
|
326
|
+
}
|
|
327
|
+
// -------------------------------------------------------------------------
|
|
328
|
+
// Local Reads (instant, from storage)
|
|
329
|
+
// -------------------------------------------------------------------------
|
|
330
|
+
/**
|
|
331
|
+
* Query records from local storage.
|
|
332
|
+
*/
|
|
333
|
+
async query(modelKey, opts) {
|
|
334
|
+
const storeName = this.storeKey(modelKey);
|
|
335
|
+
let records = await this.storage.getAll(storeName);
|
|
336
|
+
records = records.filter((r) => !r.deleted);
|
|
337
|
+
if (opts?.naturalKey) {
|
|
338
|
+
records = records.filter((r) => r.naturalKey === opts.naturalKey);
|
|
339
|
+
}
|
|
340
|
+
if (opts?.filter) {
|
|
341
|
+
records = records.filter(opts.filter);
|
|
342
|
+
}
|
|
343
|
+
if (opts?.sort) {
|
|
344
|
+
records.sort(opts.sort);
|
|
345
|
+
}
|
|
346
|
+
if (opts?.offset) {
|
|
347
|
+
records = records.slice(opts.offset);
|
|
348
|
+
}
|
|
349
|
+
if (opts?.limit) {
|
|
350
|
+
records = records.slice(0, opts.limit);
|
|
351
|
+
}
|
|
352
|
+
return records;
|
|
353
|
+
}
|
|
354
|
+
/**
|
|
355
|
+
* Get a single record by server ID.
|
|
356
|
+
*/
|
|
357
|
+
async get(modelKey, id) {
|
|
358
|
+
const record = await this.storage.get(
|
|
359
|
+
this.storeKey(modelKey),
|
|
360
|
+
id
|
|
361
|
+
);
|
|
362
|
+
if (!record || record.deleted) return null;
|
|
363
|
+
return record;
|
|
364
|
+
}
|
|
365
|
+
/**
|
|
366
|
+
* Get a record by natural key.
|
|
367
|
+
*/
|
|
368
|
+
async getByKey(modelKey, naturalKey) {
|
|
369
|
+
const records = await this.query(modelKey, { naturalKey });
|
|
370
|
+
return records[0] ?? null;
|
|
371
|
+
}
|
|
372
|
+
// -------------------------------------------------------------------------
|
|
373
|
+
// Local Writes (instant, queued for push)
|
|
374
|
+
// -------------------------------------------------------------------------
|
|
375
|
+
/**
|
|
376
|
+
* Create a record locally and queue for push.
|
|
377
|
+
*/
|
|
378
|
+
async create(modelKey, data, naturalKey) {
|
|
379
|
+
const clientId = generateClientId();
|
|
380
|
+
const record = {
|
|
381
|
+
id: "",
|
|
382
|
+
clientId,
|
|
383
|
+
modelKey,
|
|
384
|
+
naturalKey: naturalKey ?? null,
|
|
385
|
+
data,
|
|
386
|
+
metadata: null,
|
|
387
|
+
syncVersion: "0",
|
|
388
|
+
updatedAt: (/* @__PURE__ */ new Date()).toISOString(),
|
|
389
|
+
deleted: false,
|
|
390
|
+
pending: true
|
|
391
|
+
};
|
|
392
|
+
await this.storage.put(this.storeKey(modelKey), clientId, record);
|
|
393
|
+
await this.queue.enqueue({
|
|
394
|
+
clientId,
|
|
395
|
+
op: "create",
|
|
396
|
+
modelKey,
|
|
397
|
+
naturalKey,
|
|
398
|
+
data
|
|
399
|
+
});
|
|
400
|
+
const pendingCount = await this.queue.count();
|
|
401
|
+
this.emit({ type: "pending-changed", count: pendingCount });
|
|
402
|
+
this.emit({ type: "records-changed", modelKey, recordIds: [clientId] });
|
|
403
|
+
return record;
|
|
404
|
+
}
|
|
405
|
+
/**
|
|
406
|
+
* Update a record locally and queue for push.
|
|
407
|
+
*/
|
|
408
|
+
async update(modelKey, id, data) {
|
|
409
|
+
const storeName = this.storeKey(modelKey);
|
|
410
|
+
const existing = await this.storage.get(storeName, id);
|
|
411
|
+
if (!existing) return null;
|
|
412
|
+
const updated = {
|
|
413
|
+
...existing,
|
|
414
|
+
data: { ...existing.data, ...data },
|
|
415
|
+
updatedAt: (/* @__PURE__ */ new Date()).toISOString(),
|
|
416
|
+
pending: true
|
|
417
|
+
};
|
|
418
|
+
await this.storage.put(storeName, id, updated);
|
|
419
|
+
await this.queue.enqueue({
|
|
420
|
+
clientId: id,
|
|
421
|
+
op: "update",
|
|
422
|
+
modelKey,
|
|
423
|
+
data: updated.data,
|
|
424
|
+
expectedSyncVersion: existing.syncVersion
|
|
425
|
+
});
|
|
426
|
+
const pendingCount = await this.queue.count();
|
|
427
|
+
this.emit({ type: "pending-changed", count: pendingCount });
|
|
428
|
+
this.emit({ type: "records-changed", modelKey, recordIds: [id] });
|
|
429
|
+
return updated;
|
|
430
|
+
}
|
|
431
|
+
/**
|
|
432
|
+
* Delete a record locally and queue for push.
|
|
433
|
+
*/
|
|
434
|
+
async delete(modelKey, id) {
|
|
435
|
+
const storeName = this.storeKey(modelKey);
|
|
436
|
+
const existing = await this.storage.get(storeName, id);
|
|
437
|
+
if (!existing) return;
|
|
438
|
+
const deleted = {
|
|
439
|
+
...existing,
|
|
440
|
+
deleted: true,
|
|
441
|
+
pending: true,
|
|
442
|
+
updatedAt: (/* @__PURE__ */ new Date()).toISOString()
|
|
443
|
+
};
|
|
444
|
+
await this.storage.put(storeName, id, deleted);
|
|
445
|
+
await this.queue.enqueue({
|
|
446
|
+
clientId: id,
|
|
447
|
+
op: "delete",
|
|
448
|
+
modelKey,
|
|
449
|
+
expectedSyncVersion: existing.syncVersion
|
|
450
|
+
});
|
|
451
|
+
const pendingCount = await this.queue.count();
|
|
452
|
+
this.emit({ type: "pending-changed", count: pendingCount });
|
|
453
|
+
this.emit({ type: "records-changed", modelKey, recordIds: [id] });
|
|
454
|
+
}
|
|
455
|
+
/**
|
|
456
|
+
* Get count of pending mutations.
|
|
457
|
+
*/
|
|
458
|
+
async getPendingCount() {
|
|
459
|
+
return this.queue.count();
|
|
460
|
+
}
|
|
461
|
+
// -------------------------------------------------------------------------
|
|
462
|
+
// Pull — Fetch server changes
|
|
463
|
+
// -------------------------------------------------------------------------
|
|
464
|
+
async pullModel(modelKey) {
|
|
465
|
+
let totalPulled = 0;
|
|
466
|
+
let hasMore = true;
|
|
467
|
+
while (hasMore) {
|
|
468
|
+
const cursorKey = `cursor:${modelKey}`;
|
|
469
|
+
const cursor = await this.storage.getMeta(cursorKey) ?? "0";
|
|
470
|
+
const result = await this.graphqlQuery(
|
|
471
|
+
SYNC_PULL_QUERY,
|
|
472
|
+
{ modelKey, since: cursor, limit: this.config.pullLimit }
|
|
473
|
+
);
|
|
474
|
+
const delta = result.syncPull;
|
|
475
|
+
const storeName = this.storeKey(modelKey);
|
|
476
|
+
const changedIds = [];
|
|
477
|
+
for (const item of delta.items) {
|
|
478
|
+
const record = {
|
|
479
|
+
id: item.id,
|
|
480
|
+
clientId: item.id,
|
|
481
|
+
modelKey: item.modelKey,
|
|
482
|
+
naturalKey: item.naturalKey,
|
|
483
|
+
data: item.data ?? {},
|
|
484
|
+
metadata: item.metadata,
|
|
485
|
+
syncVersion: item.syncVersion,
|
|
486
|
+
updatedAt: item.updatedAt,
|
|
487
|
+
deleted: item.deleted,
|
|
488
|
+
pending: false
|
|
489
|
+
};
|
|
490
|
+
const existing = await this.storage.get(
|
|
491
|
+
storeName,
|
|
492
|
+
item.id
|
|
493
|
+
);
|
|
494
|
+
if (existing?.pending) {
|
|
495
|
+
this.log(`Skipping pull for pending record ${item.id}`);
|
|
496
|
+
continue;
|
|
497
|
+
}
|
|
498
|
+
await this.storage.put(storeName, item.id, record);
|
|
499
|
+
changedIds.push(item.id);
|
|
500
|
+
totalPulled++;
|
|
501
|
+
}
|
|
502
|
+
if (delta.cursor !== "0") {
|
|
503
|
+
await this.storage.setMeta(cursorKey, delta.cursor);
|
|
504
|
+
}
|
|
505
|
+
if (changedIds.length > 0) {
|
|
506
|
+
this.emit({ type: "records-changed", modelKey, recordIds: changedIds });
|
|
507
|
+
}
|
|
508
|
+
hasMore = delta.hasMore;
|
|
509
|
+
}
|
|
510
|
+
return totalPulled;
|
|
511
|
+
}
|
|
512
|
+
// -------------------------------------------------------------------------
|
|
513
|
+
// Push — Send local mutations to server
|
|
514
|
+
// -------------------------------------------------------------------------
|
|
515
|
+
async pushPending() {
|
|
516
|
+
let pushed = 0;
|
|
517
|
+
let conflicts = 0;
|
|
518
|
+
let errors = 0;
|
|
519
|
+
while (true) {
|
|
520
|
+
const batch = await this.queue.getBatch(this.config.pushBatchSize);
|
|
521
|
+
if (batch.length === 0) break;
|
|
522
|
+
const items = batch.map((m) => ({
|
|
523
|
+
clientId: m.clientId,
|
|
524
|
+
op: m.op,
|
|
525
|
+
modelKey: m.modelKey,
|
|
526
|
+
naturalKey: m.naturalKey,
|
|
527
|
+
data: m.data,
|
|
528
|
+
expectedSyncVersion: m.expectedSyncVersion
|
|
529
|
+
}));
|
|
530
|
+
try {
|
|
531
|
+
const result = await this.graphqlQuery(SYNC_PUSH_MUTATION, { items });
|
|
532
|
+
for (const resultItem of result.syncPush.items) {
|
|
533
|
+
const pendingMutation = batch.find(
|
|
534
|
+
(m) => m.clientId === resultItem.clientId
|
|
535
|
+
);
|
|
536
|
+
if (!pendingMutation) continue;
|
|
537
|
+
switch (resultItem.status) {
|
|
538
|
+
case "applied": {
|
|
539
|
+
await this.queue.remove(pendingMutation.queueId);
|
|
540
|
+
pushed++;
|
|
541
|
+
const modelKey = pendingMutation.modelKey;
|
|
542
|
+
const storeName = this.storeKey(modelKey);
|
|
543
|
+
if (pendingMutation.op === "create") {
|
|
544
|
+
const localRecord = await this.storage.get(
|
|
545
|
+
storeName,
|
|
546
|
+
pendingMutation.clientId
|
|
547
|
+
);
|
|
548
|
+
if (localRecord) {
|
|
549
|
+
await this.storage.delete(
|
|
550
|
+
storeName,
|
|
551
|
+
pendingMutation.clientId
|
|
552
|
+
);
|
|
553
|
+
localRecord.id = resultItem.serverId;
|
|
554
|
+
localRecord.clientId = resultItem.serverId;
|
|
555
|
+
localRecord.syncVersion = resultItem.syncVersion;
|
|
556
|
+
localRecord.pending = false;
|
|
557
|
+
await this.storage.put(
|
|
558
|
+
storeName,
|
|
559
|
+
resultItem.serverId,
|
|
560
|
+
localRecord
|
|
561
|
+
);
|
|
562
|
+
this.emit({
|
|
563
|
+
type: "records-changed",
|
|
564
|
+
modelKey,
|
|
565
|
+
recordIds: [pendingMutation.clientId, resultItem.serverId]
|
|
566
|
+
});
|
|
567
|
+
}
|
|
568
|
+
} else {
|
|
569
|
+
const existing = await this.storage.get(
|
|
570
|
+
storeName,
|
|
571
|
+
pendingMutation.clientId
|
|
572
|
+
);
|
|
573
|
+
if (existing) {
|
|
574
|
+
existing.syncVersion = resultItem.syncVersion;
|
|
575
|
+
existing.pending = false;
|
|
576
|
+
await this.storage.put(
|
|
577
|
+
storeName,
|
|
578
|
+
pendingMutation.clientId,
|
|
579
|
+
existing
|
|
580
|
+
);
|
|
581
|
+
this.emit({
|
|
582
|
+
type: "records-changed",
|
|
583
|
+
modelKey,
|
|
584
|
+
recordIds: [pendingMutation.clientId]
|
|
585
|
+
});
|
|
586
|
+
}
|
|
587
|
+
}
|
|
588
|
+
break;
|
|
589
|
+
}
|
|
590
|
+
case "conflict": {
|
|
591
|
+
conflicts++;
|
|
592
|
+
await this.queue.remove(pendingMutation.queueId);
|
|
593
|
+
const localData = pendingMutation.data ?? {};
|
|
594
|
+
const serverData = resultItem.serverData ?? {};
|
|
595
|
+
const resolution = resolveConflict(
|
|
596
|
+
localData,
|
|
597
|
+
serverData,
|
|
598
|
+
this.config.conflictStrategy,
|
|
599
|
+
this.config.customResolver
|
|
600
|
+
);
|
|
601
|
+
this.emit({
|
|
602
|
+
type: "conflict",
|
|
603
|
+
event: {
|
|
604
|
+
recordId: resultItem.serverId,
|
|
605
|
+
modelKey: pendingMutation.modelKey,
|
|
606
|
+
localData,
|
|
607
|
+
serverData,
|
|
608
|
+
resolvedData: resolution.resolved,
|
|
609
|
+
conflictedFields: resolution.conflictedFields,
|
|
610
|
+
strategy: this.config.conflictStrategy
|
|
611
|
+
}
|
|
612
|
+
});
|
|
613
|
+
const storeName = this.storeKey(pendingMutation.modelKey);
|
|
614
|
+
const existing = await this.storage.get(
|
|
615
|
+
storeName,
|
|
616
|
+
pendingMutation.clientId
|
|
617
|
+
);
|
|
618
|
+
if (existing) {
|
|
619
|
+
existing.data = resolution.resolved;
|
|
620
|
+
existing.syncVersion = resultItem.serverSyncVersion ?? existing.syncVersion;
|
|
621
|
+
existing.pending = false;
|
|
622
|
+
await this.storage.put(
|
|
623
|
+
storeName,
|
|
624
|
+
pendingMutation.clientId,
|
|
625
|
+
existing
|
|
626
|
+
);
|
|
627
|
+
}
|
|
628
|
+
if (this.config.conflictStrategy !== "server-wins" && JSON.stringify(resolution.resolved) !== JSON.stringify(serverData)) {
|
|
629
|
+
await this.queue.enqueue({
|
|
630
|
+
clientId: resultItem.serverId,
|
|
631
|
+
op: "update",
|
|
632
|
+
modelKey: pendingMutation.modelKey,
|
|
633
|
+
data: resolution.resolved,
|
|
634
|
+
expectedSyncVersion: resultItem.serverSyncVersion
|
|
635
|
+
});
|
|
636
|
+
}
|
|
637
|
+
break;
|
|
638
|
+
}
|
|
639
|
+
case "error": {
|
|
640
|
+
errors++;
|
|
641
|
+
if (pendingMutation.retryCount >= 3) {
|
|
642
|
+
await this.queue.remove(pendingMutation.queueId);
|
|
643
|
+
this.log(
|
|
644
|
+
`Giving up on mutation ${pendingMutation.queueId}: ${resultItem.error}`
|
|
645
|
+
);
|
|
646
|
+
} else {
|
|
647
|
+
await this.queue.incrementRetry(pendingMutation.queueId);
|
|
648
|
+
}
|
|
649
|
+
break;
|
|
650
|
+
}
|
|
651
|
+
}
|
|
652
|
+
}
|
|
653
|
+
} catch (error) {
|
|
654
|
+
this.log("Push failed (network):", error);
|
|
655
|
+
break;
|
|
656
|
+
}
|
|
657
|
+
}
|
|
658
|
+
const pendingCount = await this.queue.count();
|
|
659
|
+
this.emit({ type: "pending-changed", count: pendingCount });
|
|
660
|
+
return { pushed, conflicts, errors };
|
|
661
|
+
}
|
|
662
|
+
// -------------------------------------------------------------------------
|
|
663
|
+
// Subscriptions — Real-time updates via WebSocket
|
|
664
|
+
// -------------------------------------------------------------------------
|
|
665
|
+
connectSubscriptions() {
|
|
666
|
+
const wsUrl = this.config.wsUrl;
|
|
667
|
+
if (!wsUrl) {
|
|
668
|
+
this.log("No wsUrl configured \u2014 using polling only");
|
|
669
|
+
return;
|
|
670
|
+
}
|
|
671
|
+
try {
|
|
672
|
+
void this.setupWebSocket(wsUrl);
|
|
673
|
+
} catch {
|
|
674
|
+
this.log("WebSocket setup failed \u2014 using polling only");
|
|
675
|
+
}
|
|
676
|
+
}
|
|
677
|
+
async setupWebSocket(wsUrl) {
|
|
678
|
+
try {
|
|
679
|
+
const { createClient } = await import("graphql-ws");
|
|
680
|
+
const connectionParams = {};
|
|
681
|
+
if (this.config.apiKey) {
|
|
682
|
+
connectionParams["x-api-key"] = this.config.apiKey;
|
|
683
|
+
}
|
|
684
|
+
if (this.config.token) {
|
|
685
|
+
connectionParams.authorization = `Bearer ${this.config.token}`;
|
|
686
|
+
}
|
|
687
|
+
const client = createClient({
|
|
688
|
+
url: wsUrl,
|
|
689
|
+
connectionParams,
|
|
690
|
+
retryAttempts: Infinity,
|
|
691
|
+
retryWait: async (retryCount) => {
|
|
692
|
+
const delay = Math.min(1e3 * Math.pow(2, retryCount), 3e4);
|
|
693
|
+
await new Promise((resolve) => setTimeout(resolve, delay));
|
|
694
|
+
},
|
|
695
|
+
on: {
|
|
696
|
+
connected: () => {
|
|
697
|
+
this.emit({ type: "connected" });
|
|
698
|
+
this.log("WebSocket connected");
|
|
699
|
+
},
|
|
700
|
+
closed: () => {
|
|
701
|
+
this.emit({ type: "disconnected" });
|
|
702
|
+
this.log("WebSocket disconnected");
|
|
703
|
+
}
|
|
704
|
+
}
|
|
705
|
+
});
|
|
706
|
+
const cleanups = [];
|
|
707
|
+
for (const modelKey of this.config.modelKeys) {
|
|
708
|
+
const cleanup = client.subscribe(
|
|
709
|
+
{ query: RECORD_CHANGED_SUBSCRIPTION, variables: { modelKey } },
|
|
710
|
+
{
|
|
711
|
+
next: (result) => {
|
|
712
|
+
if (result.data?.recordChanged) {
|
|
713
|
+
void this.handleSubscriptionEvent(result.data.recordChanged);
|
|
714
|
+
}
|
|
715
|
+
},
|
|
716
|
+
error: (err) => {
|
|
717
|
+
this.log("Subscription error:", err);
|
|
718
|
+
},
|
|
719
|
+
complete: () => {
|
|
720
|
+
this.log(`Subscription complete for ${modelKey}`);
|
|
721
|
+
}
|
|
722
|
+
}
|
|
723
|
+
);
|
|
724
|
+
cleanups.push(cleanup);
|
|
725
|
+
}
|
|
726
|
+
this.wsCleanup = () => {
|
|
727
|
+
for (const cleanup of cleanups) {
|
|
728
|
+
cleanup();
|
|
729
|
+
}
|
|
730
|
+
void client.dispose();
|
|
731
|
+
};
|
|
732
|
+
} catch (error) {
|
|
733
|
+
this.log("WebSocket setup error:", error);
|
|
734
|
+
}
|
|
735
|
+
}
|
|
736
|
+
async handleSubscriptionEvent(event) {
|
|
737
|
+
const storeName = this.storeKey(event.modelKey);
|
|
738
|
+
const existing = await this.storage.get(
|
|
739
|
+
storeName,
|
|
740
|
+
event.recordId
|
|
741
|
+
);
|
|
742
|
+
if (existing?.pending) {
|
|
743
|
+
this.log(
|
|
744
|
+
`Skipping subscription event for pending record ${event.recordId}`
|
|
745
|
+
);
|
|
746
|
+
return;
|
|
747
|
+
}
|
|
748
|
+
if (event.type === "deleted") {
|
|
749
|
+
const record = {
|
|
750
|
+
id: event.recordId,
|
|
751
|
+
clientId: event.recordId,
|
|
752
|
+
modelKey: event.modelKey,
|
|
753
|
+
naturalKey: event.naturalKey,
|
|
754
|
+
data: {},
|
|
755
|
+
metadata: null,
|
|
756
|
+
syncVersion: event.syncVersion,
|
|
757
|
+
updatedAt: new Date(parseInt(event.timestamp)).toISOString(),
|
|
758
|
+
deleted: true,
|
|
759
|
+
pending: false
|
|
760
|
+
};
|
|
761
|
+
await this.storage.put(storeName, event.recordId, record);
|
|
762
|
+
} else {
|
|
763
|
+
const record = {
|
|
764
|
+
id: event.recordId,
|
|
765
|
+
clientId: event.recordId,
|
|
766
|
+
modelKey: event.modelKey,
|
|
767
|
+
naturalKey: event.naturalKey,
|
|
768
|
+
data: event.data ?? {},
|
|
769
|
+
metadata: null,
|
|
770
|
+
syncVersion: event.syncVersion,
|
|
771
|
+
updatedAt: new Date(parseInt(event.timestamp)).toISOString(),
|
|
772
|
+
deleted: false,
|
|
773
|
+
pending: false
|
|
774
|
+
};
|
|
775
|
+
await this.storage.put(storeName, event.recordId, record);
|
|
776
|
+
}
|
|
777
|
+
const cursorKey = `cursor:${event.modelKey}`;
|
|
778
|
+
const currentCursor = await this.storage.getMeta(cursorKey) ?? "0";
|
|
779
|
+
if (BigInt(event.syncVersion) > BigInt(currentCursor)) {
|
|
780
|
+
await this.storage.setMeta(cursorKey, event.syncVersion);
|
|
781
|
+
}
|
|
782
|
+
this.emit({
|
|
783
|
+
type: "records-changed",
|
|
784
|
+
modelKey: event.modelKey,
|
|
785
|
+
recordIds: [event.recordId]
|
|
786
|
+
});
|
|
787
|
+
}
|
|
788
|
+
// -------------------------------------------------------------------------
|
|
789
|
+
// GraphQL Transport
|
|
790
|
+
// -------------------------------------------------------------------------
|
|
791
|
+
async graphqlQuery(query, variables) {
|
|
792
|
+
const headers = {
|
|
793
|
+
"Content-Type": "application/json"
|
|
794
|
+
};
|
|
795
|
+
if (this.config.apiKey) {
|
|
796
|
+
headers["x-api-key"] = this.config.apiKey;
|
|
797
|
+
}
|
|
798
|
+
if (this.config.token) {
|
|
799
|
+
headers["Authorization"] = `Bearer ${this.config.token}`;
|
|
800
|
+
}
|
|
801
|
+
const response = await fetch(this.config.graphqlUrl, {
|
|
802
|
+
method: "POST",
|
|
803
|
+
headers,
|
|
804
|
+
body: JSON.stringify({ query, variables })
|
|
805
|
+
});
|
|
806
|
+
if (!response.ok) {
|
|
807
|
+
throw new Error(
|
|
808
|
+
`GraphQL request failed: ${response.status} ${response.statusText}`
|
|
809
|
+
);
|
|
810
|
+
}
|
|
811
|
+
const json = await response.json();
|
|
812
|
+
if (json.errors?.length) {
|
|
813
|
+
throw new Error(
|
|
814
|
+
`GraphQL errors: ${json.errors.map((e) => e.message).join(", ")}`
|
|
815
|
+
);
|
|
816
|
+
}
|
|
817
|
+
if (!json.data) {
|
|
818
|
+
throw new Error("GraphQL response missing data");
|
|
819
|
+
}
|
|
820
|
+
return json.data;
|
|
821
|
+
}
|
|
822
|
+
// -------------------------------------------------------------------------
|
|
823
|
+
// Internal Helpers
|
|
824
|
+
// -------------------------------------------------------------------------
|
|
825
|
+
storeKey(modelKey) {
|
|
826
|
+
return `records:${modelKey}`;
|
|
827
|
+
}
|
|
828
|
+
};
|
|
829
|
+
|
|
830
|
+
// src/storage/memory-adapter.ts
|
|
831
|
+
var MemoryAdapter = class {
|
|
832
|
+
constructor() {
|
|
833
|
+
this.stores = /* @__PURE__ */ new Map();
|
|
834
|
+
this.meta = /* @__PURE__ */ new Map();
|
|
835
|
+
}
|
|
836
|
+
getStore(name) {
|
|
837
|
+
let store = this.stores.get(name);
|
|
838
|
+
if (!store) {
|
|
839
|
+
store = /* @__PURE__ */ new Map();
|
|
840
|
+
this.stores.set(name, store);
|
|
841
|
+
}
|
|
842
|
+
return store;
|
|
843
|
+
}
|
|
844
|
+
async get(store, key) {
|
|
845
|
+
return this.getStore(store).get(key);
|
|
846
|
+
}
|
|
847
|
+
async put(store, key, value) {
|
|
848
|
+
this.getStore(store).set(key, value);
|
|
849
|
+
}
|
|
850
|
+
async delete(store, key) {
|
|
851
|
+
this.getStore(store).delete(key);
|
|
852
|
+
}
|
|
853
|
+
async getAll(store) {
|
|
854
|
+
return Array.from(this.getStore(store).values());
|
|
855
|
+
}
|
|
856
|
+
async clear(store) {
|
|
857
|
+
this.getStore(store).clear();
|
|
858
|
+
}
|
|
859
|
+
async getMeta(key) {
|
|
860
|
+
return this.meta.get(key);
|
|
861
|
+
}
|
|
862
|
+
async setMeta(key, value) {
|
|
863
|
+
this.meta.set(key, value);
|
|
864
|
+
}
|
|
865
|
+
};
|
|
866
|
+
|
|
867
|
+
// src/storage/indexeddb-adapter.ts
|
|
868
|
+
var idbModule = null;
|
|
869
|
+
async function getIdb() {
|
|
870
|
+
if (!idbModule) {
|
|
871
|
+
idbModule = await import("idb");
|
|
872
|
+
}
|
|
873
|
+
return idbModule;
|
|
874
|
+
}
|
|
875
|
+
var META_STORE = "__meta";
|
|
876
|
+
var IndexedDBAdapter = class {
|
|
877
|
+
constructor(dbName = "eide-sync") {
|
|
878
|
+
this.db = null;
|
|
879
|
+
this.knownStores = /* @__PURE__ */ new Set([META_STORE]);
|
|
880
|
+
this.version = 1;
|
|
881
|
+
this.dbName = dbName;
|
|
882
|
+
}
|
|
883
|
+
async ensureStore(storeName) {
|
|
884
|
+
if (this.db && this.knownStores.has(storeName)) {
|
|
885
|
+
return this.db;
|
|
886
|
+
}
|
|
887
|
+
if (this.db) {
|
|
888
|
+
this.db.close();
|
|
889
|
+
this.db = null;
|
|
890
|
+
}
|
|
891
|
+
this.knownStores.add(storeName);
|
|
892
|
+
this.version++;
|
|
893
|
+
const { openDB } = await getIdb();
|
|
894
|
+
const storeNames = [...this.knownStores];
|
|
895
|
+
this.db = await openDB(this.dbName, this.version, {
|
|
896
|
+
upgrade(db) {
|
|
897
|
+
for (const name of storeNames) {
|
|
898
|
+
if (!db.objectStoreNames.contains(name)) {
|
|
899
|
+
db.createObjectStore(name);
|
|
900
|
+
}
|
|
901
|
+
}
|
|
902
|
+
}
|
|
903
|
+
});
|
|
904
|
+
return this.db;
|
|
905
|
+
}
|
|
906
|
+
async get(store, key) {
|
|
907
|
+
const db = await this.ensureStore(store);
|
|
908
|
+
return db.get(store, key);
|
|
909
|
+
}
|
|
910
|
+
async put(store, key, value) {
|
|
911
|
+
const db = await this.ensureStore(store);
|
|
912
|
+
await db.put(store, value, key);
|
|
913
|
+
}
|
|
914
|
+
async delete(store, key) {
|
|
915
|
+
const db = await this.ensureStore(store);
|
|
916
|
+
await db.delete(store, key);
|
|
917
|
+
}
|
|
918
|
+
async getAll(store) {
|
|
919
|
+
const db = await this.ensureStore(store);
|
|
920
|
+
return db.getAll(store);
|
|
921
|
+
}
|
|
922
|
+
async clear(store) {
|
|
923
|
+
const db = await this.ensureStore(store);
|
|
924
|
+
await db.clear(store);
|
|
925
|
+
}
|
|
926
|
+
async getMeta(key) {
|
|
927
|
+
return this.get(META_STORE, key);
|
|
928
|
+
}
|
|
929
|
+
async setMeta(key, value) {
|
|
930
|
+
await this.put(META_STORE, key, value);
|
|
931
|
+
}
|
|
932
|
+
};
|
|
933
|
+
|
|
934
|
+
// src/collab/presence.ts
|
|
935
|
+
var COLORS = [
|
|
936
|
+
"#ef4444",
|
|
937
|
+
// red
|
|
938
|
+
"#f97316",
|
|
939
|
+
// orange
|
|
940
|
+
"#eab308",
|
|
941
|
+
// yellow
|
|
942
|
+
"#22c55e",
|
|
943
|
+
// green
|
|
944
|
+
"#14b8a6",
|
|
945
|
+
// teal
|
|
946
|
+
"#3b82f6",
|
|
947
|
+
// blue
|
|
948
|
+
"#8b5cf6",
|
|
949
|
+
// purple
|
|
950
|
+
"#ec4899"
|
|
951
|
+
// pink
|
|
952
|
+
];
|
|
953
|
+
function getUserColor(userId) {
|
|
954
|
+
let hash = 0;
|
|
955
|
+
for (let i = 0; i < userId.length; i++) {
|
|
956
|
+
hash = userId.charCodeAt(i) + ((hash << 5) - hash);
|
|
957
|
+
}
|
|
958
|
+
return COLORS[Math.abs(hash) % COLORS.length];
|
|
959
|
+
}
|
|
960
|
+
|
|
961
|
+
// src/collab/collab-session.ts
|
|
962
|
+
var roomSuppressionMap = /* @__PURE__ */ new Map();
|
|
963
|
+
var CollabSession = class {
|
|
964
|
+
constructor(options) {
|
|
965
|
+
// Yjs primitives
|
|
966
|
+
this._ydoc = null;
|
|
967
|
+
this._content = null;
|
|
968
|
+
this._changesLog = null;
|
|
969
|
+
this._undoManager = null;
|
|
970
|
+
this._awareness = null;
|
|
971
|
+
// WebSocket provider (y-websocket)
|
|
972
|
+
this.provider = null;
|
|
973
|
+
// State
|
|
974
|
+
this._connected = false;
|
|
975
|
+
this._synced = false;
|
|
976
|
+
this._activeUsers = [];
|
|
977
|
+
this._sessionChanges = [];
|
|
978
|
+
this._canUndo = false;
|
|
979
|
+
this._canRedo = false;
|
|
980
|
+
// Internal tracking
|
|
981
|
+
this.fieldSessions = /* @__PURE__ */ new Map();
|
|
982
|
+
this.listeners = /* @__PURE__ */ new Map();
|
|
983
|
+
this.cleanupFns = [];
|
|
984
|
+
this.options = options;
|
|
985
|
+
}
|
|
986
|
+
// ---------------------------------------------------------------------------
|
|
987
|
+
// Accessors
|
|
988
|
+
// ---------------------------------------------------------------------------
|
|
989
|
+
get ydoc() {
|
|
990
|
+
return this._ydoc;
|
|
991
|
+
}
|
|
992
|
+
get content() {
|
|
993
|
+
return this._content;
|
|
994
|
+
}
|
|
995
|
+
get changesLog() {
|
|
996
|
+
return this._changesLog;
|
|
997
|
+
}
|
|
998
|
+
get undoManager() {
|
|
999
|
+
return this._undoManager;
|
|
1000
|
+
}
|
|
1001
|
+
get awareness() {
|
|
1002
|
+
return this._awareness;
|
|
1003
|
+
}
|
|
1004
|
+
get connected() {
|
|
1005
|
+
return this._connected;
|
|
1006
|
+
}
|
|
1007
|
+
get synced() {
|
|
1008
|
+
return this._synced;
|
|
1009
|
+
}
|
|
1010
|
+
get activeUsers() {
|
|
1011
|
+
return this._activeUsers;
|
|
1012
|
+
}
|
|
1013
|
+
get sessionChanges() {
|
|
1014
|
+
return this._sessionChanges;
|
|
1015
|
+
}
|
|
1016
|
+
get canUndo() {
|
|
1017
|
+
return this._canUndo;
|
|
1018
|
+
}
|
|
1019
|
+
get canRedo() {
|
|
1020
|
+
return this._canRedo;
|
|
1021
|
+
}
|
|
1022
|
+
get room() {
|
|
1023
|
+
return this.options.room;
|
|
1024
|
+
}
|
|
1025
|
+
// ---------------------------------------------------------------------------
|
|
1026
|
+
// Event System
|
|
1027
|
+
// ---------------------------------------------------------------------------
|
|
1028
|
+
on(event, handler) {
|
|
1029
|
+
if (!this.listeners.has(event)) {
|
|
1030
|
+
this.listeners.set(event, /* @__PURE__ */ new Set());
|
|
1031
|
+
}
|
|
1032
|
+
this.listeners.get(event).add(handler);
|
|
1033
|
+
return () => {
|
|
1034
|
+
this.listeners.get(event)?.delete(handler);
|
|
1035
|
+
};
|
|
1036
|
+
}
|
|
1037
|
+
emit(event, data) {
|
|
1038
|
+
const handlers = this.listeners.get(event);
|
|
1039
|
+
if (handlers) {
|
|
1040
|
+
for (const handler of handlers) {
|
|
1041
|
+
try {
|
|
1042
|
+
handler(data);
|
|
1043
|
+
} catch {
|
|
1044
|
+
}
|
|
1045
|
+
}
|
|
1046
|
+
}
|
|
1047
|
+
}
|
|
1048
|
+
// ---------------------------------------------------------------------------
|
|
1049
|
+
// Connection Lifecycle
|
|
1050
|
+
// ---------------------------------------------------------------------------
|
|
1051
|
+
async connect() {
|
|
1052
|
+
const Y = await import("yjs");
|
|
1053
|
+
const { WebsocketProvider } = await import("y-websocket");
|
|
1054
|
+
this._ydoc = new Y.Doc();
|
|
1055
|
+
this._content = this._ydoc.getMap("content");
|
|
1056
|
+
this._changesLog = this._ydoc.getArray("changesLog");
|
|
1057
|
+
let wsUrl = this.options.wsUrl;
|
|
1058
|
+
const params = new URLSearchParams();
|
|
1059
|
+
if (this.options.auth?.apiKey) {
|
|
1060
|
+
params.set("apiKey", this.options.auth.apiKey);
|
|
1061
|
+
}
|
|
1062
|
+
if (this.options.auth?.token) {
|
|
1063
|
+
params.set("token", this.options.auth.token);
|
|
1064
|
+
}
|
|
1065
|
+
const queryString = params.toString();
|
|
1066
|
+
if (queryString) {
|
|
1067
|
+
wsUrl = `${wsUrl}${wsUrl.includes("?") ? "&" : "?"}${queryString}`;
|
|
1068
|
+
}
|
|
1069
|
+
this.provider = new WebsocketProvider(wsUrl, this.options.room, this._ydoc);
|
|
1070
|
+
this._awareness = this.provider.awareness;
|
|
1071
|
+
const handleStatus = ({ status }) => {
|
|
1072
|
+
const wasConnected = this._connected;
|
|
1073
|
+
this._connected = status === "connected";
|
|
1074
|
+
if (this._connected && !wasConnected) {
|
|
1075
|
+
this.emit("connected", void 0);
|
|
1076
|
+
} else if (!this._connected && wasConnected) {
|
|
1077
|
+
this.emit("disconnected", void 0);
|
|
1078
|
+
}
|
|
1079
|
+
};
|
|
1080
|
+
const handleSync = (isSynced) => {
|
|
1081
|
+
this._synced = isSynced;
|
|
1082
|
+
if (isSynced) {
|
|
1083
|
+
this.emit("synced", void 0);
|
|
1084
|
+
}
|
|
1085
|
+
};
|
|
1086
|
+
this.provider.on("status", handleStatus);
|
|
1087
|
+
this.provider.on("sync", handleSync);
|
|
1088
|
+
this.cleanupFns.push(() => {
|
|
1089
|
+
this.provider.off("status", handleStatus);
|
|
1090
|
+
this.provider.off("sync", handleSync);
|
|
1091
|
+
});
|
|
1092
|
+
this.setupPresence();
|
|
1093
|
+
this.setupContentObservation(Y);
|
|
1094
|
+
}
|
|
1095
|
+
disconnect() {
|
|
1096
|
+
for (const session of this.fieldSessions.values()) {
|
|
1097
|
+
if (session.debounceTimer) clearTimeout(session.debounceTimer);
|
|
1098
|
+
}
|
|
1099
|
+
this.fieldSessions.clear();
|
|
1100
|
+
for (const fn of this.cleanupFns) {
|
|
1101
|
+
try {
|
|
1102
|
+
fn();
|
|
1103
|
+
} catch {
|
|
1104
|
+
}
|
|
1105
|
+
}
|
|
1106
|
+
this.cleanupFns = [];
|
|
1107
|
+
if (this._undoManager) {
|
|
1108
|
+
this._undoManager.destroy();
|
|
1109
|
+
this._undoManager = null;
|
|
1110
|
+
}
|
|
1111
|
+
if (this.provider) {
|
|
1112
|
+
this.provider.disconnect();
|
|
1113
|
+
this.provider = null;
|
|
1114
|
+
}
|
|
1115
|
+
if (this._ydoc) {
|
|
1116
|
+
this._ydoc.destroy();
|
|
1117
|
+
this._ydoc = null;
|
|
1118
|
+
}
|
|
1119
|
+
this._content = null;
|
|
1120
|
+
this._changesLog = null;
|
|
1121
|
+
this._awareness = null;
|
|
1122
|
+
this._connected = false;
|
|
1123
|
+
this._synced = false;
|
|
1124
|
+
this._activeUsers = [];
|
|
1125
|
+
this._sessionChanges = [];
|
|
1126
|
+
this._canUndo = false;
|
|
1127
|
+
this._canRedo = false;
|
|
1128
|
+
this.emit("disconnected", void 0);
|
|
1129
|
+
}
|
|
1130
|
+
// ---------------------------------------------------------------------------
|
|
1131
|
+
// Presence
|
|
1132
|
+
// ---------------------------------------------------------------------------
|
|
1133
|
+
setupPresence() {
|
|
1134
|
+
if (!this._awareness || !this.options.user) return;
|
|
1135
|
+
const user = this.options.user;
|
|
1136
|
+
const color = user.color || getUserColor(user.id);
|
|
1137
|
+
this._awareness.setLocalState({
|
|
1138
|
+
user: { id: user.id, name: user.name, color }
|
|
1139
|
+
});
|
|
1140
|
+
const handleAwarenessChange = () => {
|
|
1141
|
+
if (!this._awareness) return;
|
|
1142
|
+
const states = this._awareness.getStates();
|
|
1143
|
+
const users = [];
|
|
1144
|
+
states.forEach((state, clientId) => {
|
|
1145
|
+
if (state?.user && clientId !== this._awareness.clientID) {
|
|
1146
|
+
users.push({
|
|
1147
|
+
userId: state.user.id,
|
|
1148
|
+
userName: state.user.name,
|
|
1149
|
+
color: state.user.color,
|
|
1150
|
+
joinedAt: (/* @__PURE__ */ new Date()).toISOString()
|
|
1151
|
+
});
|
|
1152
|
+
}
|
|
1153
|
+
});
|
|
1154
|
+
this._activeUsers = users;
|
|
1155
|
+
this.emit("presence-changed", users);
|
|
1156
|
+
};
|
|
1157
|
+
this._awareness.on("change", handleAwarenessChange);
|
|
1158
|
+
this.cleanupFns.push(
|
|
1159
|
+
() => this._awareness?.off("change", handleAwarenessChange)
|
|
1160
|
+
);
|
|
1161
|
+
handleAwarenessChange();
|
|
1162
|
+
}
|
|
1163
|
+
setUser(user) {
|
|
1164
|
+
this.options.user = user;
|
|
1165
|
+
if (this._awareness) {
|
|
1166
|
+
this._awareness.setLocalState({
|
|
1167
|
+
user: {
|
|
1168
|
+
id: user.id,
|
|
1169
|
+
name: user.name,
|
|
1170
|
+
color: user.color || getUserColor(user.id)
|
|
1171
|
+
}
|
|
1172
|
+
});
|
|
1173
|
+
}
|
|
1174
|
+
}
|
|
1175
|
+
// ---------------------------------------------------------------------------
|
|
1176
|
+
// Content Observation + UndoManager
|
|
1177
|
+
// ---------------------------------------------------------------------------
|
|
1178
|
+
setupContentObservation(Y) {
|
|
1179
|
+
if (!this._ydoc || !this._content || !this._changesLog) return;
|
|
1180
|
+
const content = this._content;
|
|
1181
|
+
const changesLog = this._changesLog;
|
|
1182
|
+
const um = new Y.UndoManager(content, {
|
|
1183
|
+
trackedOrigins: /* @__PURE__ */ new Set(["user-edit"]),
|
|
1184
|
+
captureTimeout: 2e3
|
|
1185
|
+
});
|
|
1186
|
+
this._undoManager = um;
|
|
1187
|
+
const updateUndoState = () => {
|
|
1188
|
+
const hasChanges = changesLog.length > 0;
|
|
1189
|
+
this._canUndo = hasChanges;
|
|
1190
|
+
this._canRedo = um.canRedo();
|
|
1191
|
+
this.emit("can-undo-changed", {
|
|
1192
|
+
canUndo: this._canUndo,
|
|
1193
|
+
canRedo: this._canRedo
|
|
1194
|
+
});
|
|
1195
|
+
};
|
|
1196
|
+
changesLog.observe(updateUndoState);
|
|
1197
|
+
um.on("stack-item-added", updateUndoState);
|
|
1198
|
+
um.on("stack-item-popped", updateUndoState);
|
|
1199
|
+
um.on("stack-item-updated", updateUndoState);
|
|
1200
|
+
this.cleanupFns.push(() => {
|
|
1201
|
+
changesLog.unobserve(updateUndoState);
|
|
1202
|
+
um.off("stack-item-added", updateUndoState);
|
|
1203
|
+
um.off("stack-item-popped", updateUndoState);
|
|
1204
|
+
um.off("stack-item-updated", updateUndoState);
|
|
1205
|
+
});
|
|
1206
|
+
const existing = changesLog.toArray();
|
|
1207
|
+
if (existing.length > 0) {
|
|
1208
|
+
this._sessionChanges = existing;
|
|
1209
|
+
this.emit("changelog-changed", existing);
|
|
1210
|
+
}
|
|
1211
|
+
const handleChangesLogUpdate = () => {
|
|
1212
|
+
this._sessionChanges = changesLog.toArray();
|
|
1213
|
+
this.emit("changelog-changed", this._sessionChanges);
|
|
1214
|
+
};
|
|
1215
|
+
changesLog.observe(handleChangesLogUpdate);
|
|
1216
|
+
this.cleanupFns.push(() => changesLog.unobserve(handleChangesLogUpdate));
|
|
1217
|
+
const handleChange = (event, transaction) => {
|
|
1218
|
+
if (transaction.origin === "initialize" || transaction.origin === "changes-log") {
|
|
1219
|
+
return;
|
|
1220
|
+
}
|
|
1221
|
+
const isRevert = transaction.origin === "revert";
|
|
1222
|
+
const isUndoRedo = transaction.origin === um;
|
|
1223
|
+
const isRemoteChange = !transaction.local && transaction.origin !== um;
|
|
1224
|
+
if (isUndoRedo || isRevert || isRemoteChange) {
|
|
1225
|
+
event.changes.keys.forEach((_change, key) => {
|
|
1226
|
+
const newValue = content.get(key);
|
|
1227
|
+
const origin = isUndoRedo ? "undo" : isRevert ? "revert" : "remote";
|
|
1228
|
+
this.emit("field-updated", { path: key, value: newValue, origin });
|
|
1229
|
+
});
|
|
1230
|
+
}
|
|
1231
|
+
};
|
|
1232
|
+
content.observe(handleChange);
|
|
1233
|
+
this.cleanupFns.push(() => content.unobserve(handleChange));
|
|
1234
|
+
}
|
|
1235
|
+
// ---------------------------------------------------------------------------
|
|
1236
|
+
// Content Operations
|
|
1237
|
+
// ---------------------------------------------------------------------------
|
|
1238
|
+
updateField(fieldPath, value) {
|
|
1239
|
+
if (!this._ydoc || !this._content || !this._changesLog) return;
|
|
1240
|
+
const content = this._content;
|
|
1241
|
+
const changesLog = this._changesLog;
|
|
1242
|
+
const ydoc = this._ydoc;
|
|
1243
|
+
let session = this.fieldSessions.get(fieldPath);
|
|
1244
|
+
if (!session) {
|
|
1245
|
+
session = { initialValue: content.get(fieldPath), debounceTimer: null };
|
|
1246
|
+
this.fieldSessions.set(fieldPath, session);
|
|
1247
|
+
}
|
|
1248
|
+
ydoc.transact(() => {
|
|
1249
|
+
content.set(fieldPath, value);
|
|
1250
|
+
}, "user-edit");
|
|
1251
|
+
if (session.debounceTimer) clearTimeout(session.debounceTimer);
|
|
1252
|
+
const currentSession = session;
|
|
1253
|
+
const capturedRoom = this.options.room;
|
|
1254
|
+
const userId = this.options.user?.id || "unknown";
|
|
1255
|
+
const userName = this.options.user?.name || "Unknown";
|
|
1256
|
+
session.debounceTimer = setTimeout(() => {
|
|
1257
|
+
const suppressUntil = roomSuppressionMap.get(capturedRoom) || 0;
|
|
1258
|
+
if (Date.now() < suppressUntil) {
|
|
1259
|
+
this.fieldSessions.delete(fieldPath);
|
|
1260
|
+
return;
|
|
1261
|
+
}
|
|
1262
|
+
const finalValue = content.get(fieldPath);
|
|
1263
|
+
if (JSON.stringify(finalValue) !== JSON.stringify(currentSession.initialValue)) {
|
|
1264
|
+
const change = {
|
|
1265
|
+
id: `${Date.now()}-${fieldPath}`,
|
|
1266
|
+
fieldPath,
|
|
1267
|
+
fieldValue: finalValue,
|
|
1268
|
+
previousValue: currentSession.initialValue,
|
|
1269
|
+
userId,
|
|
1270
|
+
userName,
|
|
1271
|
+
createdAt: (/* @__PURE__ */ new Date()).toISOString()
|
|
1272
|
+
};
|
|
1273
|
+
ydoc.transact(() => {
|
|
1274
|
+
changesLog.push([change]);
|
|
1275
|
+
}, "changes-log");
|
|
1276
|
+
}
|
|
1277
|
+
this.fieldSessions.delete(fieldPath);
|
|
1278
|
+
}, 1e3);
|
|
1279
|
+
}
|
|
1280
|
+
initializeContent(values, versionId) {
|
|
1281
|
+
if (!this._ydoc || !this._content) return;
|
|
1282
|
+
const content = this._content;
|
|
1283
|
+
if (content.size === 0) {
|
|
1284
|
+
this._ydoc.transact(() => {
|
|
1285
|
+
if (versionId) content.set("__versionId__", versionId);
|
|
1286
|
+
for (const [key, value] of Object.entries(values)) {
|
|
1287
|
+
content.set(key, value);
|
|
1288
|
+
}
|
|
1289
|
+
}, "initialize");
|
|
1290
|
+
}
|
|
1291
|
+
}
|
|
1292
|
+
getContent() {
|
|
1293
|
+
if (!this._content) return {};
|
|
1294
|
+
const result = {};
|
|
1295
|
+
this._content.forEach((value, key) => {
|
|
1296
|
+
if (key !== "__versionId__") {
|
|
1297
|
+
result[key] = value;
|
|
1298
|
+
}
|
|
1299
|
+
});
|
|
1300
|
+
return result;
|
|
1301
|
+
}
|
|
1302
|
+
getContentStatus() {
|
|
1303
|
+
if (!this._content) return { hasContent: false, versionId: null };
|
|
1304
|
+
const content = this._content;
|
|
1305
|
+
const hasActualContent = content.size > 0 && (content.size > 1 || !content.has("__versionId__"));
|
|
1306
|
+
if (!hasActualContent) return { hasContent: false, versionId: null };
|
|
1307
|
+
const storedVersionId = content.get("__versionId__");
|
|
1308
|
+
return { hasContent: true, versionId: storedVersionId || null };
|
|
1309
|
+
}
|
|
1310
|
+
loadContentIntoForm() {
|
|
1311
|
+
if (!this._content) return false;
|
|
1312
|
+
const content = this._content;
|
|
1313
|
+
const hasActualContent = content.size > 0 && (content.size > 1 || !content.has("__versionId__"));
|
|
1314
|
+
if (!hasActualContent) return false;
|
|
1315
|
+
content.forEach((value, key) => {
|
|
1316
|
+
if (key !== "__versionId__") {
|
|
1317
|
+
this.emit("field-updated", { path: key, value, origin: "restore" });
|
|
1318
|
+
}
|
|
1319
|
+
});
|
|
1320
|
+
return true;
|
|
1321
|
+
}
|
|
1322
|
+
// ---------------------------------------------------------------------------
|
|
1323
|
+
// Undo / Redo / Revert
|
|
1324
|
+
// ---------------------------------------------------------------------------
|
|
1325
|
+
undo() {
|
|
1326
|
+
if (!this._ydoc || !this._changesLog) return;
|
|
1327
|
+
const changesLog = this._changesLog;
|
|
1328
|
+
const changes = changesLog.toArray();
|
|
1329
|
+
if (changes.length === 0) return;
|
|
1330
|
+
const lastChange = changes[changes.length - 1];
|
|
1331
|
+
if (!lastChange) return;
|
|
1332
|
+
this._ydoc.transact(() => {
|
|
1333
|
+
changesLog.delete(changes.length - 1, 1);
|
|
1334
|
+
}, "undo-cleanup");
|
|
1335
|
+
if (lastChange.previousValue !== void 0 && this._content) {
|
|
1336
|
+
this._ydoc.transact(() => {
|
|
1337
|
+
this._content.set(lastChange.fieldPath, lastChange.previousValue);
|
|
1338
|
+
}, "revert");
|
|
1339
|
+
}
|
|
1340
|
+
}
|
|
1341
|
+
redo() {
|
|
1342
|
+
if (this._undoManager?.canRedo()) {
|
|
1343
|
+
this._undoManager.redo();
|
|
1344
|
+
}
|
|
1345
|
+
}
|
|
1346
|
+
revertField(fieldPath, previousValue, changeId) {
|
|
1347
|
+
if (!this._ydoc || !this._content || !this._changesLog) return;
|
|
1348
|
+
if (changeId) {
|
|
1349
|
+
const changes = this._changesLog.toArray();
|
|
1350
|
+
const idx = changes.findIndex((c) => c.id === changeId);
|
|
1351
|
+
if (idx !== -1) {
|
|
1352
|
+
this._ydoc.transact(() => {
|
|
1353
|
+
this._changesLog.delete(idx, 1);
|
|
1354
|
+
}, "revert-cleanup");
|
|
1355
|
+
}
|
|
1356
|
+
}
|
|
1357
|
+
if (previousValue !== void 0) {
|
|
1358
|
+
this._ydoc.transact(() => {
|
|
1359
|
+
this._content.set(fieldPath, previousValue);
|
|
1360
|
+
}, "revert");
|
|
1361
|
+
}
|
|
1362
|
+
}
|
|
1363
|
+
revertAll() {
|
|
1364
|
+
if (!this._undoManager) return;
|
|
1365
|
+
while (this._undoManager.canUndo()) {
|
|
1366
|
+
this._undoManager.undo();
|
|
1367
|
+
}
|
|
1368
|
+
this._sessionChanges = [];
|
|
1369
|
+
for (const session of this.fieldSessions.values()) {
|
|
1370
|
+
if (session.debounceTimer) clearTimeout(session.debounceTimer);
|
|
1371
|
+
}
|
|
1372
|
+
this.fieldSessions.clear();
|
|
1373
|
+
}
|
|
1374
|
+
// ---------------------------------------------------------------------------
|
|
1375
|
+
// Session Management
|
|
1376
|
+
// ---------------------------------------------------------------------------
|
|
1377
|
+
async clearSession() {
|
|
1378
|
+
if (!this._ydoc) return;
|
|
1379
|
+
const room = this.options.room;
|
|
1380
|
+
roomSuppressionMap.set(room, Date.now() + 2e3);
|
|
1381
|
+
if (this._changesLog) {
|
|
1382
|
+
this._ydoc.transact(() => {
|
|
1383
|
+
this._changesLog.delete(0, this._changesLog.length);
|
|
1384
|
+
}, "clear-session");
|
|
1385
|
+
}
|
|
1386
|
+
if (this._content) {
|
|
1387
|
+
this._ydoc.transact(() => {
|
|
1388
|
+
this._content.clear();
|
|
1389
|
+
}, "clear-session");
|
|
1390
|
+
}
|
|
1391
|
+
this._sessionChanges = [];
|
|
1392
|
+
for (const session of this.fieldSessions.values()) {
|
|
1393
|
+
if (session.debounceTimer) clearTimeout(session.debounceTimer);
|
|
1394
|
+
}
|
|
1395
|
+
this.fieldSessions.clear();
|
|
1396
|
+
if (this._undoManager) this._undoManager.clear();
|
|
1397
|
+
if (this.options.apiUrl) {
|
|
1398
|
+
try {
|
|
1399
|
+
const headers = {
|
|
1400
|
+
"Content-Type": "application/json"
|
|
1401
|
+
};
|
|
1402
|
+
const fetchOpts = {
|
|
1403
|
+
method: "POST",
|
|
1404
|
+
headers,
|
|
1405
|
+
body: JSON.stringify({ room })
|
|
1406
|
+
};
|
|
1407
|
+
if (this.options.auth?.sessionCookie) {
|
|
1408
|
+
fetchOpts.credentials = "include";
|
|
1409
|
+
}
|
|
1410
|
+
if (this.options.auth?.apiKey) {
|
|
1411
|
+
headers["x-api-key"] = this.options.auth.apiKey;
|
|
1412
|
+
}
|
|
1413
|
+
if (this.options.auth?.token) {
|
|
1414
|
+
headers["Authorization"] = `Bearer ${this.options.auth.token}`;
|
|
1415
|
+
}
|
|
1416
|
+
await fetch(`${this.options.apiUrl}/api/yjs/clear-session`, fetchOpts);
|
|
1417
|
+
} catch {
|
|
1418
|
+
}
|
|
1419
|
+
}
|
|
1420
|
+
}
|
|
1421
|
+
/**
|
|
1422
|
+
* Suppress changes for a duration (ms). Used to prevent phantom
|
|
1423
|
+
* change entries after save/clear.
|
|
1424
|
+
*/
|
|
1425
|
+
suppressChanges(durationMs) {
|
|
1426
|
+
roomSuppressionMap.set(this.options.room, Date.now() + durationMs);
|
|
1427
|
+
}
|
|
1428
|
+
clearContent() {
|
|
1429
|
+
if (!this._ydoc || !this._content || !this._changesLog) return;
|
|
1430
|
+
this._ydoc.transact(() => {
|
|
1431
|
+
this._content.clear();
|
|
1432
|
+
this._changesLog.delete(0, this._changesLog.length);
|
|
1433
|
+
}, "clear-stale");
|
|
1434
|
+
this._sessionChanges = [];
|
|
1435
|
+
this.emit("changelog-changed", []);
|
|
1436
|
+
}
|
|
1437
|
+
};
|
|
1438
|
+
|
|
1439
|
+
// src/bridge/bridge.ts
|
|
1440
|
+
var AUTO_SAVE_DEBOUNCE_MS = 2e3;
|
|
1441
|
+
var SyncBridge = class {
|
|
1442
|
+
constructor(engine, session, options) {
|
|
1443
|
+
this.cleanupFns = [];
|
|
1444
|
+
this.autoSaveTimer = null;
|
|
1445
|
+
/** Tracks our own writes to prevent re-applying them from subscription */
|
|
1446
|
+
this.selfWriteVersions = /* @__PURE__ */ new Set();
|
|
1447
|
+
this.engine = engine;
|
|
1448
|
+
this.session = session;
|
|
1449
|
+
this.options = { autoSave: true, ...options };
|
|
1450
|
+
this.setup();
|
|
1451
|
+
}
|
|
1452
|
+
setup() {
|
|
1453
|
+
if (this.options.autoSave) {
|
|
1454
|
+
const handleFieldUpdate = (event) => {
|
|
1455
|
+
if (event.origin !== "user-edit") return;
|
|
1456
|
+
if (this.autoSaveTimer) clearTimeout(this.autoSaveTimer);
|
|
1457
|
+
this.autoSaveTimer = setTimeout(() => {
|
|
1458
|
+
this.autoSaveTimer = null;
|
|
1459
|
+
void this.autoSave();
|
|
1460
|
+
}, AUTO_SAVE_DEBOUNCE_MS);
|
|
1461
|
+
};
|
|
1462
|
+
if (this.session.ydoc) {
|
|
1463
|
+
const content = this.session.ydoc.getMap("content");
|
|
1464
|
+
const observer = (_event, transaction) => {
|
|
1465
|
+
if (transaction.origin === "user-edit") {
|
|
1466
|
+
handleFieldUpdate({ path: "", value: null, origin: "user-edit" });
|
|
1467
|
+
}
|
|
1468
|
+
};
|
|
1469
|
+
content.observe(observer);
|
|
1470
|
+
this.cleanupFns.push(() => content.unobserve(observer));
|
|
1471
|
+
}
|
|
1472
|
+
}
|
|
1473
|
+
const handleSyncEvent = (event) => {
|
|
1474
|
+
if (event.type !== "records-changed") return;
|
|
1475
|
+
if (event.modelKey !== this.options.modelKey) return;
|
|
1476
|
+
if (!event.recordIds.includes(this.options.recordId)) return;
|
|
1477
|
+
void this.applyRemoteChanges();
|
|
1478
|
+
};
|
|
1479
|
+
const unsubSync = this.engine.on(handleSyncEvent);
|
|
1480
|
+
this.cleanupFns.push(unsubSync);
|
|
1481
|
+
}
|
|
1482
|
+
/**
|
|
1483
|
+
* Auto-save: read content from CollabSession Y.Doc, push via SyncEngine.
|
|
1484
|
+
*/
|
|
1485
|
+
async autoSave() {
|
|
1486
|
+
const data = this.session.getContent();
|
|
1487
|
+
if (Object.keys(data).length === 0) return;
|
|
1488
|
+
try {
|
|
1489
|
+
const result = await this.engine.update(
|
|
1490
|
+
this.options.modelKey,
|
|
1491
|
+
this.options.recordId,
|
|
1492
|
+
data
|
|
1493
|
+
);
|
|
1494
|
+
if (result) {
|
|
1495
|
+
this.selfWriteVersions.add(result.syncVersion);
|
|
1496
|
+
if (this.selfWriteVersions.size > 20) {
|
|
1497
|
+
const first = this.selfWriteVersions.values().next().value;
|
|
1498
|
+
if (first) this.selfWriteVersions.delete(first);
|
|
1499
|
+
}
|
|
1500
|
+
}
|
|
1501
|
+
} catch {
|
|
1502
|
+
}
|
|
1503
|
+
}
|
|
1504
|
+
/**
|
|
1505
|
+
* Apply remote changes from SyncEngine to CollabSession Y.Doc.
|
|
1506
|
+
* Uses 'remote-sync' origin to prevent re-pushing.
|
|
1507
|
+
*/
|
|
1508
|
+
async applyRemoteChanges() {
|
|
1509
|
+
const record = await this.engine.get(
|
|
1510
|
+
this.options.modelKey,
|
|
1511
|
+
this.options.recordId
|
|
1512
|
+
);
|
|
1513
|
+
if (!record) return;
|
|
1514
|
+
if (this.selfWriteVersions.has(record.syncVersion)) {
|
|
1515
|
+
this.selfWriteVersions.delete(record.syncVersion);
|
|
1516
|
+
return;
|
|
1517
|
+
}
|
|
1518
|
+
if (this.session.ydoc) {
|
|
1519
|
+
const content = this.session.ydoc.getMap("content");
|
|
1520
|
+
this.session.ydoc.transact(() => {
|
|
1521
|
+
for (const [key, value] of Object.entries(record.data)) {
|
|
1522
|
+
const currentValue = content.get(key);
|
|
1523
|
+
if (JSON.stringify(currentValue) !== JSON.stringify(value)) {
|
|
1524
|
+
content.set(key, value);
|
|
1525
|
+
}
|
|
1526
|
+
}
|
|
1527
|
+
}, "remote-sync");
|
|
1528
|
+
}
|
|
1529
|
+
}
|
|
1530
|
+
/**
|
|
1531
|
+
* Manual save: used for "version save" mode (admin clicks Save button).
|
|
1532
|
+
* Reads content from CollabSession, pushes immediately, then clears session.
|
|
1533
|
+
*/
|
|
1534
|
+
async saveVersion() {
|
|
1535
|
+
const data = this.session.getContent();
|
|
1536
|
+
await this.engine.update(
|
|
1537
|
+
this.options.modelKey,
|
|
1538
|
+
this.options.recordId,
|
|
1539
|
+
data
|
|
1540
|
+
);
|
|
1541
|
+
await this.engine.sync();
|
|
1542
|
+
await this.session.clearSession();
|
|
1543
|
+
return data;
|
|
1544
|
+
}
|
|
1545
|
+
/**
|
|
1546
|
+
* Destroy the bridge and clean up all subscriptions.
|
|
1547
|
+
*/
|
|
1548
|
+
destroy() {
|
|
1549
|
+
if (this.autoSaveTimer) {
|
|
1550
|
+
clearTimeout(this.autoSaveTimer);
|
|
1551
|
+
this.autoSaveTimer = null;
|
|
1552
|
+
}
|
|
1553
|
+
for (const fn of this.cleanupFns) {
|
|
1554
|
+
try {
|
|
1555
|
+
fn();
|
|
1556
|
+
} catch {
|
|
1557
|
+
}
|
|
1558
|
+
}
|
|
1559
|
+
this.cleanupFns = [];
|
|
1560
|
+
this.selfWriteVersions.clear();
|
|
1561
|
+
}
|
|
1562
|
+
};
|
|
1563
|
+
export {
|
|
1564
|
+
CollabSession,
|
|
1565
|
+
IndexedDBAdapter,
|
|
1566
|
+
MemoryAdapter,
|
|
1567
|
+
OfflineQueue,
|
|
1568
|
+
SyncBridge,
|
|
1569
|
+
SyncEngine,
|
|
1570
|
+
getUserColor,
|
|
1571
|
+
resolveConflict
|
|
1572
|
+
};
|