| 1 | import * as Y from 'yjs';
|
|---|
| 2 |
|
|---|
| 3 | // Global CRDT document cache
|
|---|
| 4 | // Key: "siteId:postId"
|
|---|
| 5 | // Value: { ydoc, lastSaved, updateCallbacks }
|
|---|
| 6 | const documents = new Map();
|
|---|
| 7 |
|
|---|
| 8 | export class CRDTService {
|
|---|
| 9 | /**
|
|---|
| 10 | * Get or create a Yjs document for a post
|
|---|
| 11 | */
|
|---|
| 12 | static getDocument(siteId, postId, initialBinary = null) {
|
|---|
| 13 | const key = `${siteId}:${postId}`;
|
|---|
| 14 |
|
|---|
| 15 | if (documents.has(key)) {
|
|---|
| 16 | return documents.get(key).ydoc;
|
|---|
| 17 | }
|
|---|
| 18 |
|
|---|
| 19 | // Create new document
|
|---|
| 20 | const ydoc = new Y.Doc();
|
|---|
| 21 | const ytext = ydoc.getText('content');
|
|---|
| 22 | const ymeta = ydoc.getMap('metadata');
|
|---|
| 23 |
|
|---|
| 24 | // If we have stored binary, restore it
|
|---|
| 25 | if (initialBinary && initialBinary.length > 0) {
|
|---|
| 26 | try {
|
|---|
| 27 | Y.applyUpdate(ydoc, initialBinary);
|
|---|
| 28 | } catch (err) {
|
|---|
| 29 | console.error(`⚠️ Failed to restore CRDT for ${key}:`, err);
|
|---|
| 30 | }
|
|---|
| 31 | }
|
|---|
| 32 |
|
|---|
| 33 | documents.set(key, {
|
|---|
| 34 | ydoc,
|
|---|
| 35 | lastSaved: Date.now(),
|
|---|
| 36 | updateCallbacks: []
|
|---|
| 37 | });
|
|---|
| 38 |
|
|---|
| 39 | return ydoc;
|
|---|
| 40 | }
|
|---|
| 41 |
|
|---|
| 42 | /**
|
|---|
| 43 | * Get plain text content from document (for FTS5 + display)
|
|---|
| 44 | */
|
|---|
| 45 | static getPlainText(siteId, postId) {
|
|---|
| 46 | const ydoc = this.getDocument(siteId, postId);
|
|---|
| 47 | const ytext = ydoc.getText('content');
|
|---|
| 48 | return ytext.toString();
|
|---|
| 49 | }
|
|---|
| 50 |
|
|---|
| 51 | /**
|
|---|
| 52 | * Apply an update to a document
|
|---|
| 53 | */
|
|---|
| 54 | static applyUpdate(siteId, postId, updateBinary) {
|
|---|
| 55 | const key = `${siteId}:${postId}`;
|
|---|
| 56 | const ydoc = this.getDocument(siteId, postId);
|
|---|
| 57 |
|
|---|
| 58 | try {
|
|---|
| 59 | Y.applyUpdate(ydoc, updateBinary);
|
|---|
| 60 | if (documents.has(key)) {
|
|---|
| 61 | documents.get(key).lastSaved = Date.now();
|
|---|
| 62 | }
|
|---|
| 63 | return true;
|
|---|
| 64 | } catch (err) {
|
|---|
| 65 | console.error(`⚠️ Failed to apply update for ${key}:`, err);
|
|---|
| 66 | return false;
|
|---|
| 67 | }
|
|---|
| 68 | }
|
|---|
| 69 |
|
|---|
| 70 | /**
|
|---|
| 71 | * Get full state as binary (for persistence to SQLite)
|
|---|
| 72 | */
|
|---|
| 73 | static encodeState(siteId, postId) {
|
|---|
| 74 | const ydoc = this.getDocument(siteId, postId);
|
|---|
| 75 | return Y.encodeStateAsUpdate(ydoc);
|
|---|
| 76 | }
|
|---|
| 77 |
|
|---|
| 78 | /**
|
|---|
| 79 | * Register a callback for document updates
|
|---|
| 80 | */
|
|---|
| 81 | static onUpdate(siteId, postId, callback) {
|
|---|
| 82 | const key = `${siteId}:${postId}`;
|
|---|
| 83 | const ydoc = this.getDocument(siteId, postId);
|
|---|
| 84 |
|
|---|
| 85 | // Store callback for later cleanup
|
|---|
| 86 | if (!documents.has(key)) {
|
|---|
| 87 | documents.set(key, { ydoc, lastSaved: Date.now(), updateCallbacks: [] });
|
|---|
| 88 | }
|
|---|
| 89 | documents.get(key).updateCallbacks.push(callback);
|
|---|
| 90 |
|
|---|
| 91 | // Trigger callback on every update
|
|---|
| 92 | const listener = (update, origin) => {
|
|---|
| 93 | if (origin !== 'local') {
|
|---|
| 94 | callback(update);
|
|---|
| 95 | }
|
|---|
| 96 | };
|
|---|
| 97 |
|
|---|
| 98 | ydoc.on('update', listener);
|
|---|
| 99 |
|
|---|
| 100 | // Return unsubscribe function
|
|---|
| 101 | return () => {
|
|---|
| 102 | ydoc.off('update', listener);
|
|---|
| 103 | };
|
|---|
| 104 | }
|
|---|
| 105 |
|
|---|
| 106 | /**
|
|---|
| 107 | * Clean up old documents from memory
|
|---|
| 108 | */
|
|---|
| 109 | static cleanup(olderThanMinutes = 60) {
|
|---|
| 110 | const cutoff = Date.now() - (olderThanMinutes * 60 * 1000);
|
|---|
| 111 | let cleaned = 0;
|
|---|
| 112 |
|
|---|
| 113 | for (const [key, value] of documents.entries()) {
|
|---|
| 114 | if (value.lastSaved < cutoff) {
|
|---|
| 115 | value.ydoc.destroy();
|
|---|
| 116 | documents.delete(key);
|
|---|
| 117 | cleaned++;
|
|---|
| 118 | }
|
|---|
| 119 | }
|
|---|
| 120 |
|
|---|
| 121 | if (cleaned > 0) {
|
|---|
| 122 | console.log(`🧹 CRDT: Cleaned ${cleaned} old documents`);
|
|---|
| 123 | }
|
|---|
| 124 | }
|
|---|
| 125 |
|
|---|
| 126 | /**
|
|---|
| 127 | * Destroy a document
|
|---|
| 128 | */
|
|---|
| 129 | static destroy(siteId, postId) {
|
|---|
| 130 | const key = `${siteId}:${postId}`;
|
|---|
| 131 | if (documents.has(key)) {
|
|---|
| 132 | documents.get(key).ydoc.destroy();
|
|---|
| 133 | documents.delete(key);
|
|---|
| 134 | }
|
|---|
| 135 | }
|
|---|
| 136 |
|
|---|
| 137 | /**
|
|---|
| 138 | * Get stats (for debugging)
|
|---|
| 139 | */
|
|---|
| 140 | static getStats() {
|
|---|
| 141 | return {
|
|---|
| 142 | documentsInMemory: documents.size,
|
|---|
| 143 | documents: Array.from(documents.keys())
|
|---|
| 144 | };
|
|---|
| 145 | }
|
|---|
| 146 | }
|
|---|
| 147 |
|
|---|
| 148 | export default CRDTService;
|
|---|