@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/react.js ADDED
@@ -0,0 +1,1857 @@
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/react/SyncProvider.tsx
1440
+ import {
1441
+ createContext,
1442
+ useContext,
1443
+ useEffect,
1444
+ useRef,
1445
+ useState
1446
+ } from "react";
1447
+ import { jsx } from "react/jsx-runtime";
1448
+ var SyncContext = createContext(null);
1449
+ function SyncProvider({
1450
+ config,
1451
+ storage,
1452
+ children,
1453
+ autoStart = true
1454
+ }) {
1455
+ const engineRef = useRef(null);
1456
+ const [connected, setConnected] = useState(false);
1457
+ const [pendingCount, setPendingCount] = useState(0);
1458
+ const [lastSyncError, setLastSyncError] = useState(null);
1459
+ if (!engineRef.current) {
1460
+ engineRef.current = new SyncEngine(config, storage);
1461
+ }
1462
+ const engine = engineRef.current;
1463
+ useEffect(() => {
1464
+ const handler = (event) => {
1465
+ switch (event.type) {
1466
+ case "connected":
1467
+ setConnected(true);
1468
+ break;
1469
+ case "disconnected":
1470
+ setConnected(false);
1471
+ break;
1472
+ case "pending-changed":
1473
+ setPendingCount(event.count);
1474
+ break;
1475
+ case "sync-error":
1476
+ setLastSyncError(event.error);
1477
+ break;
1478
+ case "sync-complete":
1479
+ setLastSyncError(null);
1480
+ break;
1481
+ }
1482
+ };
1483
+ const unsubscribe = engine.on(handler);
1484
+ if (autoStart) {
1485
+ engine.start();
1486
+ }
1487
+ return () => {
1488
+ unsubscribe();
1489
+ engine.stop();
1490
+ };
1491
+ }, [engine, autoStart]);
1492
+ return /* @__PURE__ */ jsx(
1493
+ SyncContext.Provider,
1494
+ {
1495
+ value: { engine, connected, pendingCount, lastSyncError },
1496
+ children
1497
+ }
1498
+ );
1499
+ }
1500
+ function useSyncContext() {
1501
+ const ctx = useContext(SyncContext);
1502
+ if (!ctx) {
1503
+ throw new Error("useSyncContext must be used within a <SyncProvider>");
1504
+ }
1505
+ return ctx;
1506
+ }
1507
+
1508
+ // src/react/useSyncQuery.ts
1509
+ import { useCallback, useEffect as useEffect2, useRef as useRef2, useState as useState2 } from "react";
1510
+ function useSyncQuery(modelKey, options) {
1511
+ const { engine } = useSyncContext();
1512
+ const [data, setData] = useState2([]);
1513
+ const [loading, setLoading] = useState2(true);
1514
+ const [error, setError] = useState2(null);
1515
+ const optionsRef = useRef2(options);
1516
+ optionsRef.current = options;
1517
+ const fetchData = useCallback(async () => {
1518
+ try {
1519
+ const records = await engine.query(modelKey, optionsRef.current);
1520
+ setData(records);
1521
+ setError(null);
1522
+ } catch (err) {
1523
+ setError(err instanceof Error ? err : new Error(String(err)));
1524
+ } finally {
1525
+ setLoading(false);
1526
+ }
1527
+ }, [engine, modelKey]);
1528
+ useEffect2(() => {
1529
+ void fetchData();
1530
+ }, [fetchData]);
1531
+ useEffect2(() => {
1532
+ const handler = (event) => {
1533
+ if (event.type === "records-changed" && event.modelKey === modelKey) {
1534
+ void fetchData();
1535
+ }
1536
+ if (event.type === "sync-complete") {
1537
+ void fetchData();
1538
+ }
1539
+ };
1540
+ return engine.on(handler);
1541
+ }, [engine, modelKey, fetchData]);
1542
+ return { data, loading, error, refetch: fetchData };
1543
+ }
1544
+ function useSyncRecord(modelKey, id) {
1545
+ const { engine } = useSyncContext();
1546
+ const [data, setData] = useState2(null);
1547
+ const [loading, setLoading] = useState2(true);
1548
+ const fetchData = useCallback(async () => {
1549
+ if (!id) {
1550
+ setData(null);
1551
+ setLoading(false);
1552
+ return;
1553
+ }
1554
+ try {
1555
+ const record = await engine.get(modelKey, id);
1556
+ setData(record);
1557
+ } finally {
1558
+ setLoading(false);
1559
+ }
1560
+ }, [engine, modelKey, id]);
1561
+ useEffect2(() => {
1562
+ void fetchData();
1563
+ }, [fetchData]);
1564
+ useEffect2(() => {
1565
+ const handler = (event) => {
1566
+ if (event.type === "records-changed" && event.modelKey === modelKey && id && event.recordIds.includes(id)) {
1567
+ void fetchData();
1568
+ }
1569
+ };
1570
+ return engine.on(handler);
1571
+ }, [engine, modelKey, id, fetchData]);
1572
+ return { data, loading };
1573
+ }
1574
+
1575
+ // src/react/useSyncMutation.ts
1576
+ import { useCallback as useCallback2 } from "react";
1577
+ function useSyncMutation() {
1578
+ const { engine, pendingCount } = useSyncContext();
1579
+ const create = useCallback2(
1580
+ async (modelKey, data, naturalKey) => {
1581
+ return engine.create(modelKey, data, naturalKey);
1582
+ },
1583
+ [engine]
1584
+ );
1585
+ const update = useCallback2(
1586
+ async (modelKey, id, data) => {
1587
+ return engine.update(modelKey, id, data);
1588
+ },
1589
+ [engine]
1590
+ );
1591
+ const remove = useCallback2(
1592
+ async (modelKey, id) => {
1593
+ return engine.delete(modelKey, id);
1594
+ },
1595
+ [engine]
1596
+ );
1597
+ const sync = useCallback2(async () => {
1598
+ await engine.sync();
1599
+ }, [engine]);
1600
+ return { create, update, remove, sync, pendingCount };
1601
+ }
1602
+
1603
+ // src/react/useCollaborativeSession.ts
1604
+ import { useCallback as useCallback3, useEffect as useEffect3, useRef as useRef3, useState as useState3 } from "react";
1605
+ function useCollaborativeSession(options) {
1606
+ const sessionRef = useRef3(null);
1607
+ const [connected, setConnected] = useState3(false);
1608
+ const [synced, setSynced] = useState3(false);
1609
+ const [activeUsers, setActiveUsers] = useState3([]);
1610
+ const [changelog, setChangelog] = useState3([]);
1611
+ const [canUndo, setCanUndo] = useState3(false);
1612
+ const [canRedo, setCanRedo] = useState3(false);
1613
+ const onFieldUpdateRef = useRef3(options.onFieldUpdate);
1614
+ onFieldUpdateRef.current = options.onFieldUpdate;
1615
+ const enabled = options.enabled ?? true;
1616
+ const room = options.room;
1617
+ useEffect3(() => {
1618
+ if (!enabled || !room) return;
1619
+ const session = new CollabSession({
1620
+ wsUrl: options.wsUrl,
1621
+ room: options.room,
1622
+ user: options.user,
1623
+ apiUrl: options.apiUrl,
1624
+ auth: options.auth
1625
+ });
1626
+ sessionRef.current = session;
1627
+ const unsubs = [];
1628
+ unsubs.push(session.on("connected", () => setConnected(true)));
1629
+ unsubs.push(session.on("disconnected", () => setConnected(false)));
1630
+ unsubs.push(session.on("synced", () => setSynced(true)));
1631
+ unsubs.push(
1632
+ session.on("presence-changed", (users) => setActiveUsers(users))
1633
+ );
1634
+ unsubs.push(
1635
+ session.on("changelog-changed", (changes) => setChangelog(changes))
1636
+ );
1637
+ unsubs.push(
1638
+ session.on("can-undo-changed", (state) => {
1639
+ setCanUndo(state.canUndo);
1640
+ setCanRedo(state.canRedo);
1641
+ })
1642
+ );
1643
+ unsubs.push(
1644
+ session.on("field-updated", (event) => {
1645
+ onFieldUpdateRef.current?.(event.path, event.value, event.origin);
1646
+ })
1647
+ );
1648
+ void session.connect();
1649
+ return () => {
1650
+ for (const unsub of unsubs) unsub();
1651
+ session.disconnect();
1652
+ sessionRef.current = null;
1653
+ setConnected(false);
1654
+ setSynced(false);
1655
+ setActiveUsers([]);
1656
+ setChangelog([]);
1657
+ setCanUndo(false);
1658
+ setCanRedo(false);
1659
+ };
1660
+ }, [room, enabled]);
1661
+ const updateField = useCallback3(
1662
+ (fieldPath, value) => sessionRef.current?.updateField(fieldPath, value),
1663
+ []
1664
+ );
1665
+ const undo = useCallback3(() => sessionRef.current?.undo(), []);
1666
+ const redo = useCallback3(() => sessionRef.current?.redo(), []);
1667
+ const revertField = useCallback3(
1668
+ (fieldPath, previousValue, changeId) => sessionRef.current?.revertField(fieldPath, previousValue, changeId),
1669
+ []
1670
+ );
1671
+ const revertAll = useCallback3(() => sessionRef.current?.revertAll(), []);
1672
+ const getContent = useCallback3(
1673
+ () => sessionRef.current?.getContent() ?? {},
1674
+ []
1675
+ );
1676
+ const getContentStatus = useCallback3(
1677
+ () => sessionRef.current?.getContentStatus() ?? {
1678
+ hasContent: false,
1679
+ versionId: null
1680
+ },
1681
+ []
1682
+ );
1683
+ const initializeContent = useCallback3(
1684
+ (values, versionId) => sessionRef.current?.initializeContent(values, versionId),
1685
+ []
1686
+ );
1687
+ const loadContentIntoForm = useCallback3(
1688
+ () => sessionRef.current?.loadContentIntoForm() ?? false,
1689
+ []
1690
+ );
1691
+ const clearSession = useCallback3(async () => {
1692
+ await sessionRef.current?.clearSession();
1693
+ }, []);
1694
+ const clearContent = useCallback3(
1695
+ () => sessionRef.current?.clearContent(),
1696
+ []
1697
+ );
1698
+ return {
1699
+ session: sessionRef.current,
1700
+ connected,
1701
+ synced,
1702
+ activeUsers,
1703
+ changelog,
1704
+ canUndo,
1705
+ canRedo,
1706
+ updateField,
1707
+ undo,
1708
+ redo,
1709
+ revertField,
1710
+ revertAll,
1711
+ getContent,
1712
+ getContentStatus,
1713
+ initializeContent,
1714
+ loadContentIntoForm,
1715
+ clearSession,
1716
+ clearContent
1717
+ };
1718
+ }
1719
+
1720
+ // src/bridge/bridge.ts
1721
+ var AUTO_SAVE_DEBOUNCE_MS = 2e3;
1722
+ var SyncBridge = class {
1723
+ constructor(engine, session, options) {
1724
+ this.cleanupFns = [];
1725
+ this.autoSaveTimer = null;
1726
+ /** Tracks our own writes to prevent re-applying them from subscription */
1727
+ this.selfWriteVersions = /* @__PURE__ */ new Set();
1728
+ this.engine = engine;
1729
+ this.session = session;
1730
+ this.options = { autoSave: true, ...options };
1731
+ this.setup();
1732
+ }
1733
+ setup() {
1734
+ if (this.options.autoSave) {
1735
+ const handleFieldUpdate = (event) => {
1736
+ if (event.origin !== "user-edit") return;
1737
+ if (this.autoSaveTimer) clearTimeout(this.autoSaveTimer);
1738
+ this.autoSaveTimer = setTimeout(() => {
1739
+ this.autoSaveTimer = null;
1740
+ void this.autoSave();
1741
+ }, AUTO_SAVE_DEBOUNCE_MS);
1742
+ };
1743
+ if (this.session.ydoc) {
1744
+ const content = this.session.ydoc.getMap("content");
1745
+ const observer = (_event, transaction) => {
1746
+ if (transaction.origin === "user-edit") {
1747
+ handleFieldUpdate({ path: "", value: null, origin: "user-edit" });
1748
+ }
1749
+ };
1750
+ content.observe(observer);
1751
+ this.cleanupFns.push(() => content.unobserve(observer));
1752
+ }
1753
+ }
1754
+ const handleSyncEvent = (event) => {
1755
+ if (event.type !== "records-changed") return;
1756
+ if (event.modelKey !== this.options.modelKey) return;
1757
+ if (!event.recordIds.includes(this.options.recordId)) return;
1758
+ void this.applyRemoteChanges();
1759
+ };
1760
+ const unsubSync = this.engine.on(handleSyncEvent);
1761
+ this.cleanupFns.push(unsubSync);
1762
+ }
1763
+ /**
1764
+ * Auto-save: read content from CollabSession Y.Doc, push via SyncEngine.
1765
+ */
1766
+ async autoSave() {
1767
+ const data = this.session.getContent();
1768
+ if (Object.keys(data).length === 0) return;
1769
+ try {
1770
+ const result = await this.engine.update(
1771
+ this.options.modelKey,
1772
+ this.options.recordId,
1773
+ data
1774
+ );
1775
+ if (result) {
1776
+ this.selfWriteVersions.add(result.syncVersion);
1777
+ if (this.selfWriteVersions.size > 20) {
1778
+ const first = this.selfWriteVersions.values().next().value;
1779
+ if (first) this.selfWriteVersions.delete(first);
1780
+ }
1781
+ }
1782
+ } catch {
1783
+ }
1784
+ }
1785
+ /**
1786
+ * Apply remote changes from SyncEngine to CollabSession Y.Doc.
1787
+ * Uses 'remote-sync' origin to prevent re-pushing.
1788
+ */
1789
+ async applyRemoteChanges() {
1790
+ const record = await this.engine.get(
1791
+ this.options.modelKey,
1792
+ this.options.recordId
1793
+ );
1794
+ if (!record) return;
1795
+ if (this.selfWriteVersions.has(record.syncVersion)) {
1796
+ this.selfWriteVersions.delete(record.syncVersion);
1797
+ return;
1798
+ }
1799
+ if (this.session.ydoc) {
1800
+ const content = this.session.ydoc.getMap("content");
1801
+ this.session.ydoc.transact(() => {
1802
+ for (const [key, value] of Object.entries(record.data)) {
1803
+ const currentValue = content.get(key);
1804
+ if (JSON.stringify(currentValue) !== JSON.stringify(value)) {
1805
+ content.set(key, value);
1806
+ }
1807
+ }
1808
+ }, "remote-sync");
1809
+ }
1810
+ }
1811
+ /**
1812
+ * Manual save: used for "version save" mode (admin clicks Save button).
1813
+ * Reads content from CollabSession, pushes immediately, then clears session.
1814
+ */
1815
+ async saveVersion() {
1816
+ const data = this.session.getContent();
1817
+ await this.engine.update(
1818
+ this.options.modelKey,
1819
+ this.options.recordId,
1820
+ data
1821
+ );
1822
+ await this.engine.sync();
1823
+ await this.session.clearSession();
1824
+ return data;
1825
+ }
1826
+ /**
1827
+ * Destroy the bridge and clean up all subscriptions.
1828
+ */
1829
+ destroy() {
1830
+ if (this.autoSaveTimer) {
1831
+ clearTimeout(this.autoSaveTimer);
1832
+ this.autoSaveTimer = null;
1833
+ }
1834
+ for (const fn of this.cleanupFns) {
1835
+ try {
1836
+ fn();
1837
+ } catch {
1838
+ }
1839
+ }
1840
+ this.cleanupFns = [];
1841
+ this.selfWriteVersions.clear();
1842
+ }
1843
+ };
1844
+ export {
1845
+ CollabSession,
1846
+ IndexedDBAdapter,
1847
+ MemoryAdapter,
1848
+ SyncBridge,
1849
+ SyncEngine,
1850
+ SyncProvider,
1851
+ getUserColor,
1852
+ useCollaborativeSession,
1853
+ useSyncContext,
1854
+ useSyncMutation,
1855
+ useSyncQuery,
1856
+ useSyncRecord
1857
+ };