@fatagnus/dink-sync 1.0.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/README.md +312 -0
- package/dist/client/attachment.d.ts +225 -0
- package/dist/client/attachment.d.ts.map +1 -0
- package/dist/client/attachment.js +402 -0
- package/dist/client/attachment.js.map +1 -0
- package/dist/client/binary-encoding.d.ts +45 -0
- package/dist/client/binary-encoding.d.ts.map +1 -0
- package/dist/client/binary-encoding.js +90 -0
- package/dist/client/binary-encoding.js.map +1 -0
- package/dist/client/collection.d.ts +10 -0
- package/dist/client/collection.d.ts.map +1 -0
- package/dist/client/collection.js +924 -0
- package/dist/client/collection.js.map +1 -0
- package/dist/client/compression.d.ts +56 -0
- package/dist/client/compression.d.ts.map +1 -0
- package/dist/client/compression.js +173 -0
- package/dist/client/compression.js.map +1 -0
- package/dist/client/crdt/index.d.ts +2 -0
- package/dist/client/crdt/index.d.ts.map +1 -0
- package/dist/client/crdt/index.js +2 -0
- package/dist/client/crdt/index.js.map +1 -0
- package/dist/client/crdt/yjs-doc.d.ts +88 -0
- package/dist/client/crdt/yjs-doc.d.ts.map +1 -0
- package/dist/client/crdt/yjs-doc.js +123 -0
- package/dist/client/crdt/yjs-doc.js.map +1 -0
- package/dist/client/index.d.ts +66 -0
- package/dist/client/index.d.ts.map +1 -0
- package/dist/client/index.js +233 -0
- package/dist/client/index.js.map +1 -0
- package/dist/client/mock-transport.d.ts +155 -0
- package/dist/client/mock-transport.d.ts.map +1 -0
- package/dist/client/mock-transport.js +292 -0
- package/dist/client/mock-transport.js.map +1 -0
- package/dist/client/network-detector.d.ts +65 -0
- package/dist/client/network-detector.d.ts.map +1 -0
- package/dist/client/network-detector.js +147 -0
- package/dist/client/network-detector.js.map +1 -0
- package/dist/client/provisioning.d.ts +126 -0
- package/dist/client/provisioning.d.ts.map +1 -0
- package/dist/client/provisioning.js +125 -0
- package/dist/client/provisioning.js.map +1 -0
- package/dist/client/signal.d.ts +13 -0
- package/dist/client/signal.d.ts.map +1 -0
- package/dist/client/signal.js +27 -0
- package/dist/client/signal.js.map +1 -0
- package/dist/client/sync-engine.d.ts +298 -0
- package/dist/client/sync-engine.d.ts.map +1 -0
- package/dist/client/sync-engine.js +904 -0
- package/dist/client/sync-engine.js.map +1 -0
- package/dist/client/synced-edge.d.ts +109 -0
- package/dist/client/synced-edge.d.ts.map +1 -0
- package/dist/client/synced-edge.js +179 -0
- package/dist/client/synced-edge.js.map +1 -0
- package/dist/client/synced-offline-edge-types.d.ts +540 -0
- package/dist/client/synced-offline-edge-types.d.ts.map +1 -0
- package/dist/client/synced-offline-edge-types.js +10 -0
- package/dist/client/synced-offline-edge-types.js.map +1 -0
- package/dist/client/synced-offline-edge.d.ts +54 -0
- package/dist/client/synced-offline-edge.d.ts.map +1 -0
- package/dist/client/synced-offline-edge.js +731 -0
- package/dist/client/synced-offline-edge.js.map +1 -0
- package/dist/client/transport.d.ts +202 -0
- package/dist/client/transport.d.ts.map +1 -0
- package/dist/client/transport.js +409 -0
- package/dist/client/transport.js.map +1 -0
- package/dist/client/types.d.ts +622 -0
- package/dist/client/types.d.ts.map +1 -0
- package/dist/client/types.js +60 -0
- package/dist/client/types.js.map +1 -0
- package/dist/client/validation.d.ts +61 -0
- package/dist/client/validation.d.ts.map +1 -0
- package/dist/client/validation.js +57 -0
- package/dist/client/validation.js.map +1 -0
- package/dist/client/versioning.d.ts +134 -0
- package/dist/client/versioning.d.ts.map +1 -0
- package/dist/client/versioning.js +304 -0
- package/dist/client/versioning.js.map +1 -0
- package/dist/index.d.ts +40 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +51 -0
- package/dist/index.js.map +1 -0
- package/dist/persistence/encryption.d.ts +114 -0
- package/dist/persistence/encryption.d.ts.map +1 -0
- package/dist/persistence/encryption.js +286 -0
- package/dist/persistence/encryption.js.map +1 -0
- package/dist/persistence/index.d.ts +21 -0
- package/dist/persistence/index.d.ts.map +1 -0
- package/dist/persistence/index.js +20 -0
- package/dist/persistence/index.js.map +1 -0
- package/dist/persistence/memory.d.ts +32 -0
- package/dist/persistence/memory.d.ts.map +1 -0
- package/dist/persistence/memory.js +57 -0
- package/dist/persistence/memory.js.map +1 -0
- package/dist/persistence/migrations.d.ts +106 -0
- package/dist/persistence/migrations.d.ts.map +1 -0
- package/dist/persistence/migrations.js +176 -0
- package/dist/persistence/migrations.js.map +1 -0
- package/dist/persistence/pending-queue.d.ts +109 -0
- package/dist/persistence/pending-queue.d.ts.map +1 -0
- package/dist/persistence/pending-queue.js +249 -0
- package/dist/persistence/pending-queue.js.map +1 -0
- package/dist/persistence/pglite.d.ts +72 -0
- package/dist/persistence/pglite.d.ts.map +1 -0
- package/dist/persistence/pglite.js +126 -0
- package/dist/persistence/pglite.js.map +1 -0
- package/dist/persistence/quota-manager.d.ts +134 -0
- package/dist/persistence/quota-manager.d.ts.map +1 -0
- package/dist/persistence/quota-manager.js +242 -0
- package/dist/persistence/quota-manager.js.map +1 -0
- package/dist/persistence/types.d.ts +54 -0
- package/dist/persistence/types.d.ts.map +1 -0
- package/dist/persistence/types.js +2 -0
- package/dist/persistence/types.js.map +1 -0
- package/dist/react/OfflineEdgeProvider.d.ts +91 -0
- package/dist/react/OfflineEdgeProvider.d.ts.map +1 -0
- package/dist/react/OfflineEdgeProvider.js +127 -0
- package/dist/react/OfflineEdgeProvider.js.map +1 -0
- package/dist/react/SyncedOfflineEdgeProvider.d.ts +105 -0
- package/dist/react/SyncedOfflineEdgeProvider.d.ts.map +1 -0
- package/dist/react/SyncedOfflineEdgeProvider.js +138 -0
- package/dist/react/SyncedOfflineEdgeProvider.js.map +1 -0
- package/dist/react/index.d.ts +50 -0
- package/dist/react/index.d.ts.map +1 -0
- package/dist/react/index.js +51 -0
- package/dist/react/index.js.map +1 -0
- package/dist/react/useCollection.d.ts +77 -0
- package/dist/react/useCollection.d.ts.map +1 -0
- package/dist/react/useCollection.js +113 -0
- package/dist/react/useCollection.js.map +1 -0
- package/dist/react/useCollectionSyncMode.d.ts +61 -0
- package/dist/react/useCollectionSyncMode.d.ts.map +1 -0
- package/dist/react/useCollectionSyncMode.js +93 -0
- package/dist/react/useCollectionSyncMode.js.map +1 -0
- package/dist/react/useConnectionState.d.ts +44 -0
- package/dist/react/useConnectionState.d.ts.map +1 -0
- package/dist/react/useConnectionState.js +46 -0
- package/dist/react/useConnectionState.js.map +1 -0
- package/dist/react/useDocumentSyncStatus.d.ts +72 -0
- package/dist/react/useDocumentSyncStatus.d.ts.map +1 -0
- package/dist/react/useDocumentSyncStatus.js +110 -0
- package/dist/react/useDocumentSyncStatus.js.map +1 -0
- package/dist/react/useOfflineEdge.d.ts +58 -0
- package/dist/react/useOfflineEdge.d.ts.map +1 -0
- package/dist/react/useOfflineEdge.js +54 -0
- package/dist/react/useOfflineEdge.js.map +1 -0
- package/dist/react/usePendingChanges.d.ts +67 -0
- package/dist/react/usePendingChanges.d.ts.map +1 -0
- package/dist/react/usePendingChanges.js +90 -0
- package/dist/react/usePendingChanges.js.map +1 -0
- package/dist/react/useRejectedDocuments.d.ts +112 -0
- package/dist/react/useRejectedDocuments.d.ts.map +1 -0
- package/dist/react/useRejectedDocuments.js +213 -0
- package/dist/react/useRejectedDocuments.js.map +1 -0
- package/dist/react/useSyncControls.d.ts +96 -0
- package/dist/react/useSyncControls.d.ts.map +1 -0
- package/dist/react/useSyncControls.js +112 -0
- package/dist/react/useSyncControls.js.map +1 -0
- package/dist/react/useSyncProgress.d.ts +78 -0
- package/dist/react/useSyncProgress.d.ts.map +1 -0
- package/dist/react/useSyncProgress.js +90 -0
- package/dist/react/useSyncProgress.js.map +1 -0
- package/dist/react/useSyncRejected.d.ts +47 -0
- package/dist/react/useSyncRejected.d.ts.map +1 -0
- package/dist/react/useSyncRejected.js +55 -0
- package/dist/react/useSyncRejected.js.map +1 -0
- package/dist/react/useSyncStatus.d.ts +56 -0
- package/dist/react/useSyncStatus.d.ts.map +1 -0
- package/dist/react/useSyncStatus.js +59 -0
- package/dist/react/useSyncStatus.js.map +1 -0
- package/dist/react/useSyncedOfflineEdge.d.ts +69 -0
- package/dist/react/useSyncedOfflineEdge.d.ts.map +1 -0
- package/dist/react/useSyncedOfflineEdge.js +65 -0
- package/dist/react/useSyncedOfflineEdge.js.map +1 -0
- package/dist/service-worker/index.d.ts +7 -0
- package/dist/service-worker/index.d.ts.map +1 -0
- package/dist/service-worker/index.js +7 -0
- package/dist/service-worker/index.js.map +1 -0
- package/dist/service-worker/sync-worker.d.ts +230 -0
- package/dist/service-worker/sync-worker.d.ts.map +1 -0
- package/dist/service-worker/sync-worker.js +471 -0
- package/dist/service-worker/sync-worker.js.map +1 -0
- package/dist/types.d.ts +6 -0
- package/dist/types.d.ts.map +1 -0
- package/dist/types.js +3 -0
- package/dist/types.js.map +1 -0
- package/package.json +95 -0
|
@@ -0,0 +1,924 @@
|
|
|
1
|
+
import { CollectionValidationError } from './validation.js';
|
|
2
|
+
import { SyncStatus, SyncPriority as SyncPriorityEnum } from './types.js';
|
|
3
|
+
import { YjsDocument } from './crdt/yjs-doc.js';
|
|
4
|
+
/**
|
|
5
|
+
* Generate a unique document ID.
|
|
6
|
+
*/
|
|
7
|
+
function generateId() {
|
|
8
|
+
// Use crypto.randomUUID if available, otherwise fallback
|
|
9
|
+
if (typeof crypto !== 'undefined' && crypto.randomUUID) {
|
|
10
|
+
return crypto.randomUUID();
|
|
11
|
+
}
|
|
12
|
+
// Fallback for environments without crypto.randomUUID
|
|
13
|
+
return `${Date.now().toString(36)}-${Math.random().toString(36).substring(2, 11)}`;
|
|
14
|
+
}
|
|
15
|
+
/**
|
|
16
|
+
* Encode a stored document to Uint8Array for persistence.
|
|
17
|
+
* Format: [4 bytes yjsUpdate length][yjsUpdate bytes][JSON sync metadata]
|
|
18
|
+
*/
|
|
19
|
+
function encodeStoredDocument(stored) {
|
|
20
|
+
const syncJson = JSON.stringify(stored.sync);
|
|
21
|
+
const syncBytes = new TextEncoder().encode(syncJson);
|
|
22
|
+
// Create buffer: 4 bytes for yjsUpdate length + yjsUpdate + syncBytes
|
|
23
|
+
const buffer = new Uint8Array(4 + stored.yjsUpdate.length + syncBytes.length);
|
|
24
|
+
// Write yjsUpdate length as 4 bytes (big endian)
|
|
25
|
+
const view = new DataView(buffer.buffer);
|
|
26
|
+
view.setUint32(0, stored.yjsUpdate.length, false);
|
|
27
|
+
// Write yjsUpdate
|
|
28
|
+
buffer.set(stored.yjsUpdate, 4);
|
|
29
|
+
// Write sync metadata
|
|
30
|
+
buffer.set(syncBytes, 4 + stored.yjsUpdate.length);
|
|
31
|
+
return buffer;
|
|
32
|
+
}
|
|
33
|
+
/**
|
|
34
|
+
* Decode a Uint8Array to a stored document.
|
|
35
|
+
*/
|
|
36
|
+
function decodeStoredDocument(data) {
|
|
37
|
+
// Try new binary format first
|
|
38
|
+
if (data.length >= 4) {
|
|
39
|
+
const view = new DataView(data.buffer, data.byteOffset, data.byteLength);
|
|
40
|
+
const yjsUpdateLength = view.getUint32(0, false);
|
|
41
|
+
// Validate that this looks like our binary format
|
|
42
|
+
if (yjsUpdateLength > 0 && yjsUpdateLength < data.length - 4) {
|
|
43
|
+
const yjsUpdate = data.slice(4, 4 + yjsUpdateLength);
|
|
44
|
+
const syncBytes = data.slice(4 + yjsUpdateLength);
|
|
45
|
+
try {
|
|
46
|
+
const syncJson = new TextDecoder().decode(syncBytes);
|
|
47
|
+
const sync = JSON.parse(syncJson);
|
|
48
|
+
return { yjsUpdate, sync };
|
|
49
|
+
}
|
|
50
|
+
catch {
|
|
51
|
+
// Fall through to legacy format
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
// Legacy format: plain JSON with doc and sync fields
|
|
56
|
+
try {
|
|
57
|
+
const json = new TextDecoder().decode(data);
|
|
58
|
+
const parsed = JSON.parse(json);
|
|
59
|
+
// Convert legacy document to YjsDocument format
|
|
60
|
+
const yjsDoc = new YjsDocument();
|
|
61
|
+
if (parsed.doc) {
|
|
62
|
+
// Store each field from the legacy document
|
|
63
|
+
for (const [key, value] of Object.entries(parsed.doc)) {
|
|
64
|
+
yjsDoc.set(key, value);
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
else {
|
|
68
|
+
// Even older format: just the document itself
|
|
69
|
+
for (const [key, value] of Object.entries(parsed)) {
|
|
70
|
+
if (key !== 'sync') {
|
|
71
|
+
yjsDoc.set(key, value);
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
const yjsUpdate = yjsDoc.encodeStateAsUpdate();
|
|
76
|
+
yjsDoc.destroy();
|
|
77
|
+
return {
|
|
78
|
+
yjsUpdate,
|
|
79
|
+
sync: parsed.sync ?? { status: SyncStatus.Synced },
|
|
80
|
+
};
|
|
81
|
+
}
|
|
82
|
+
catch {
|
|
83
|
+
// If all else fails, return empty document
|
|
84
|
+
const emptyDoc = new YjsDocument();
|
|
85
|
+
const yjsUpdate = emptyDoc.encodeStateAsUpdate();
|
|
86
|
+
emptyDoc.destroy();
|
|
87
|
+
return {
|
|
88
|
+
yjsUpdate,
|
|
89
|
+
sync: { status: SyncStatus.Synced },
|
|
90
|
+
};
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
/**
|
|
94
|
+
* Extract document fields from a YjsDocument as a typed object.
|
|
95
|
+
*/
|
|
96
|
+
function extractDocFromYjs(yjsDoc, id) {
|
|
97
|
+
const doc = { id };
|
|
98
|
+
// Get all fields from the Yjs document
|
|
99
|
+
const yDoc = yjsDoc.getYDoc();
|
|
100
|
+
const fields = yDoc.getMap('fields');
|
|
101
|
+
fields.forEach((value, key) => {
|
|
102
|
+
if (key !== 'id') {
|
|
103
|
+
doc[key] = yjsDoc.get(key);
|
|
104
|
+
}
|
|
105
|
+
});
|
|
106
|
+
return doc;
|
|
107
|
+
}
|
|
108
|
+
/**
|
|
109
|
+
* Create a SyncedDocument from a document and its sync metadata.
|
|
110
|
+
*/
|
|
111
|
+
function createSyncedDocument(doc, sync) {
|
|
112
|
+
return Object.assign({}, doc, { _sync: sync });
|
|
113
|
+
}
|
|
114
|
+
/**
|
|
115
|
+
* Create initial sync metadata for a new local document.
|
|
116
|
+
*/
|
|
117
|
+
function createLocalSyncMetadata() {
|
|
118
|
+
return {
|
|
119
|
+
status: SyncStatus.Local,
|
|
120
|
+
lastSyncAttempt: undefined,
|
|
121
|
+
lastSynced: undefined,
|
|
122
|
+
retryCount: 0,
|
|
123
|
+
};
|
|
124
|
+
}
|
|
125
|
+
/**
|
|
126
|
+
* Creates a collection implementation backed by a persistence provider.
|
|
127
|
+
* Uses YjsDocument internally for CRDT-based conflict-free merging.
|
|
128
|
+
*/
|
|
129
|
+
export function createCollection(name, persistence) {
|
|
130
|
+
// In-memory cache of YjsDocuments and sync metadata
|
|
131
|
+
const yjsDocs = new Map();
|
|
132
|
+
const syncMetadataCache = new Map();
|
|
133
|
+
const subscribers = new Set();
|
|
134
|
+
const rejectedSubscribers = new Set();
|
|
135
|
+
const conflictSubscribers = new Set();
|
|
136
|
+
const conflicts = new Map();
|
|
137
|
+
const tombstones = new Map();
|
|
138
|
+
const documentPriorities = new Map();
|
|
139
|
+
let initialized = false;
|
|
140
|
+
let validator = null;
|
|
141
|
+
// Default tombstone retention: 7 days
|
|
142
|
+
const DEFAULT_TOMBSTONE_RETENTION = 7 * 24 * 60 * 60 * 1000;
|
|
143
|
+
let tombstoneRetention = DEFAULT_TOMBSTONE_RETENTION;
|
|
144
|
+
// Track last update timestamp for conflict resolution
|
|
145
|
+
const lastUpdateTimestamps = new Map();
|
|
146
|
+
// Tombstone collection name for persistence
|
|
147
|
+
const TOMBSTONE_COLLECTION = `_tombstones_${name}`;
|
|
148
|
+
/**
|
|
149
|
+
* Persist a tombstone to storage.
|
|
150
|
+
*/
|
|
151
|
+
const persistTombstone = async (tombstone) => {
|
|
152
|
+
const data = new TextEncoder().encode(JSON.stringify(tombstone));
|
|
153
|
+
await persistence.save(TOMBSTONE_COLLECTION, tombstone.documentId, data);
|
|
154
|
+
};
|
|
155
|
+
/**
|
|
156
|
+
* Delete a tombstone from storage.
|
|
157
|
+
*/
|
|
158
|
+
const deleteTombstoneFromPersistence = async (id) => {
|
|
159
|
+
await persistence.delete(TOMBSTONE_COLLECTION, id);
|
|
160
|
+
};
|
|
161
|
+
/**
|
|
162
|
+
* Load tombstones from persistence.
|
|
163
|
+
*/
|
|
164
|
+
const loadTombstones = async () => {
|
|
165
|
+
const tombstoneIds = await persistence.list(TOMBSTONE_COLLECTION);
|
|
166
|
+
for (const id of tombstoneIds) {
|
|
167
|
+
const data = await persistence.load(TOMBSTONE_COLLECTION, id);
|
|
168
|
+
if (data) {
|
|
169
|
+
try {
|
|
170
|
+
const tombstone = JSON.parse(new TextDecoder().decode(data));
|
|
171
|
+
tombstones.set(id, tombstone);
|
|
172
|
+
}
|
|
173
|
+
catch {
|
|
174
|
+
// Ignore corrupted tombstone data
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
};
|
|
179
|
+
/**
|
|
180
|
+
* Run validation on document data if validator is set.
|
|
181
|
+
* @param doc - Document data without id
|
|
182
|
+
* @param skipValidation - Whether to skip validation
|
|
183
|
+
* @throws CollectionValidationError if validation fails
|
|
184
|
+
*/
|
|
185
|
+
const runValidation = (doc, skipValidation) => {
|
|
186
|
+
if (skipValidation || !validator) {
|
|
187
|
+
return;
|
|
188
|
+
}
|
|
189
|
+
const result = validator(doc);
|
|
190
|
+
if (!result.valid) {
|
|
191
|
+
throw new CollectionValidationError(result.errors);
|
|
192
|
+
}
|
|
193
|
+
};
|
|
194
|
+
// Load initial data from persistence
|
|
195
|
+
const initPromise = (async () => {
|
|
196
|
+
// Load tombstones first
|
|
197
|
+
await loadTombstones();
|
|
198
|
+
const docIds = await persistence.list(name);
|
|
199
|
+
for (const docId of docIds) {
|
|
200
|
+
const data = await persistence.load(name, docId);
|
|
201
|
+
if (data) {
|
|
202
|
+
const stored = decodeStoredDocument(data);
|
|
203
|
+
// Create YjsDocument and apply the stored update
|
|
204
|
+
const yjsDoc = new YjsDocument(docId);
|
|
205
|
+
yjsDoc.applyUpdate(stored.yjsUpdate);
|
|
206
|
+
yjsDocs.set(docId, yjsDoc);
|
|
207
|
+
syncMetadataCache.set(docId, stored.sync);
|
|
208
|
+
// Track the last sync time as the update timestamp for conflict resolution
|
|
209
|
+
if (stored.sync.lastSynced) {
|
|
210
|
+
lastUpdateTimestamps.set(docId, stored.sync.lastSynced);
|
|
211
|
+
}
|
|
212
|
+
else if (stored.sync.lastSyncAttempt) {
|
|
213
|
+
lastUpdateTimestamps.set(docId, stored.sync.lastSyncAttempt);
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
initialized = true;
|
|
218
|
+
})();
|
|
219
|
+
const getDocFromCache = (id) => {
|
|
220
|
+
const yjsDoc = yjsDocs.get(id);
|
|
221
|
+
if (!yjsDoc)
|
|
222
|
+
return null;
|
|
223
|
+
return extractDocFromYjs(yjsDoc, id);
|
|
224
|
+
};
|
|
225
|
+
const getSyncedDocs = () => {
|
|
226
|
+
const docs = [];
|
|
227
|
+
for (const [id, yjsDoc] of yjsDocs) {
|
|
228
|
+
const doc = extractDocFromYjs(yjsDoc, id);
|
|
229
|
+
const sync = syncMetadataCache.get(id) ?? { status: SyncStatus.Synced };
|
|
230
|
+
docs.push(createSyncedDocument(doc, sync));
|
|
231
|
+
}
|
|
232
|
+
return docs;
|
|
233
|
+
};
|
|
234
|
+
const notifySubscribers = () => {
|
|
235
|
+
const docs = getSyncedDocs();
|
|
236
|
+
subscribers.forEach(callback => callback(docs));
|
|
237
|
+
};
|
|
238
|
+
const notifyRejected = (event) => {
|
|
239
|
+
rejectedSubscribers.forEach(callback => callback(event));
|
|
240
|
+
};
|
|
241
|
+
const notifyConflictDetected = (event) => {
|
|
242
|
+
conflictSubscribers.forEach(callback => callback(event));
|
|
243
|
+
};
|
|
244
|
+
const ensureInitialized = async () => {
|
|
245
|
+
if (!initialized) {
|
|
246
|
+
await initPromise;
|
|
247
|
+
}
|
|
248
|
+
};
|
|
249
|
+
const persistDocument = async (id, yjsDoc, sync) => {
|
|
250
|
+
const stored = {
|
|
251
|
+
yjsUpdate: yjsDoc.encodeStateAsUpdate(),
|
|
252
|
+
sync,
|
|
253
|
+
};
|
|
254
|
+
const encoded = encodeStoredDocument(stored);
|
|
255
|
+
await persistence.save(name, id, encoded);
|
|
256
|
+
};
|
|
257
|
+
return {
|
|
258
|
+
name,
|
|
259
|
+
setValidator(newValidator) {
|
|
260
|
+
validator = newValidator;
|
|
261
|
+
},
|
|
262
|
+
async insert(doc, options) {
|
|
263
|
+
await ensureInitialized();
|
|
264
|
+
// Run validation before any persistence
|
|
265
|
+
runValidation(doc, options?.skipValidation);
|
|
266
|
+
const id = generateId();
|
|
267
|
+
const now = Date.now();
|
|
268
|
+
const syncMetadata = createLocalSyncMetadata();
|
|
269
|
+
// Create YjsDocument and set all fields
|
|
270
|
+
const yjsDoc = new YjsDocument(id);
|
|
271
|
+
for (const [key, value] of Object.entries(doc)) {
|
|
272
|
+
yjsDoc.set(key, value);
|
|
273
|
+
}
|
|
274
|
+
// Store in cache
|
|
275
|
+
yjsDocs.set(id, yjsDoc);
|
|
276
|
+
syncMetadataCache.set(id, syncMetadata);
|
|
277
|
+
// Track update timestamp for conflict resolution
|
|
278
|
+
lastUpdateTimestamps.set(id, now);
|
|
279
|
+
// Set priority if specified, otherwise default to normal
|
|
280
|
+
documentPriorities.set(id, options?.priority ?? SyncPriorityEnum.Normal);
|
|
281
|
+
// Persist to storage
|
|
282
|
+
await persistDocument(id, yjsDoc, syncMetadata);
|
|
283
|
+
// Notify subscribers
|
|
284
|
+
notifySubscribers();
|
|
285
|
+
const fullDoc = extractDocFromYjs(yjsDoc, id);
|
|
286
|
+
return createSyncedDocument(fullDoc, syncMetadata);
|
|
287
|
+
},
|
|
288
|
+
async update(id, updates, options) {
|
|
289
|
+
await ensureInitialized();
|
|
290
|
+
const yjsDoc = yjsDocs.get(id);
|
|
291
|
+
if (!yjsDoc) {
|
|
292
|
+
throw new Error(`Document not found: ${id}`);
|
|
293
|
+
}
|
|
294
|
+
// Get current document data and merge with updates for validation
|
|
295
|
+
const currentDoc = extractDocFromYjs(yjsDoc, id);
|
|
296
|
+
const { id: _id, ...currentData } = currentDoc;
|
|
297
|
+
const mergedDoc = { ...currentData, ...updates };
|
|
298
|
+
// Run validation on merged document before any changes
|
|
299
|
+
runValidation(mergedDoc, options?.skipValidation);
|
|
300
|
+
const now = Date.now();
|
|
301
|
+
// Apply updates to YjsDocument (creates CRDT delta internally)
|
|
302
|
+
for (const [key, value] of Object.entries(updates)) {
|
|
303
|
+
yjsDoc.set(key, value);
|
|
304
|
+
}
|
|
305
|
+
// Track update timestamp for conflict resolution
|
|
306
|
+
lastUpdateTimestamps.set(id, now);
|
|
307
|
+
// Update priority if specified
|
|
308
|
+
if (options?.priority) {
|
|
309
|
+
documentPriorities.set(id, options.priority);
|
|
310
|
+
}
|
|
311
|
+
// Reset to local status when document is modified
|
|
312
|
+
const existingSync = syncMetadataCache.get(id);
|
|
313
|
+
const syncMetadata = {
|
|
314
|
+
status: SyncStatus.Local,
|
|
315
|
+
lastSyncAttempt: existingSync?.lastSyncAttempt,
|
|
316
|
+
lastSynced: existingSync?.lastSynced,
|
|
317
|
+
retryCount: 0,
|
|
318
|
+
};
|
|
319
|
+
syncMetadataCache.set(id, syncMetadata);
|
|
320
|
+
// Persist to storage
|
|
321
|
+
await persistDocument(id, yjsDoc, syncMetadata);
|
|
322
|
+
// Notify subscribers
|
|
323
|
+
notifySubscribers();
|
|
324
|
+
const fullDoc = extractDocFromYjs(yjsDoc, id);
|
|
325
|
+
return createSyncedDocument(fullDoc, syncMetadata);
|
|
326
|
+
},
|
|
327
|
+
async delete(id) {
|
|
328
|
+
await ensureInitialized();
|
|
329
|
+
// Create tombstone record before deleting
|
|
330
|
+
const tombstone = {
|
|
331
|
+
documentId: id,
|
|
332
|
+
deletedAt: Date.now(),
|
|
333
|
+
synced: false,
|
|
334
|
+
};
|
|
335
|
+
tombstones.set(id, tombstone);
|
|
336
|
+
// Persist tombstone
|
|
337
|
+
await persistTombstone(tombstone);
|
|
338
|
+
// Clean up YjsDocument
|
|
339
|
+
const yjsDoc = yjsDocs.get(id);
|
|
340
|
+
if (yjsDoc) {
|
|
341
|
+
yjsDoc.destroy();
|
|
342
|
+
yjsDocs.delete(id);
|
|
343
|
+
}
|
|
344
|
+
syncMetadataCache.delete(id);
|
|
345
|
+
lastUpdateTimestamps.delete(id);
|
|
346
|
+
documentPriorities.delete(id);
|
|
347
|
+
// Clean up any conflicts
|
|
348
|
+
conflicts.delete(id);
|
|
349
|
+
// Delete from persistence
|
|
350
|
+
await persistence.delete(name, id);
|
|
351
|
+
// Notify subscribers
|
|
352
|
+
notifySubscribers();
|
|
353
|
+
},
|
|
354
|
+
async get(id) {
|
|
355
|
+
await ensureInitialized();
|
|
356
|
+
const doc = getDocFromCache(id);
|
|
357
|
+
if (!doc)
|
|
358
|
+
return null;
|
|
359
|
+
const sync = syncMetadataCache.get(id) ?? { status: SyncStatus.Synced };
|
|
360
|
+
return createSyncedDocument(doc, sync);
|
|
361
|
+
},
|
|
362
|
+
async list() {
|
|
363
|
+
await ensureInitialized();
|
|
364
|
+
return getSyncedDocs();
|
|
365
|
+
},
|
|
366
|
+
subscribe(callback) {
|
|
367
|
+
subscribers.add(callback);
|
|
368
|
+
// Call immediately with current data (after init)
|
|
369
|
+
// But only if still subscribed when init completes
|
|
370
|
+
ensureInitialized().then(() => {
|
|
371
|
+
if (subscribers.has(callback)) {
|
|
372
|
+
callback(getSyncedDocs());
|
|
373
|
+
}
|
|
374
|
+
});
|
|
375
|
+
return () => {
|
|
376
|
+
subscribers.delete(callback);
|
|
377
|
+
};
|
|
378
|
+
},
|
|
379
|
+
onSyncRejected(callback) {
|
|
380
|
+
rejectedSubscribers.add(callback);
|
|
381
|
+
return () => {
|
|
382
|
+
rejectedSubscribers.delete(callback);
|
|
383
|
+
};
|
|
384
|
+
},
|
|
385
|
+
async getSyncStatus(id) {
|
|
386
|
+
await ensureInitialized();
|
|
387
|
+
const sync = syncMetadataCache.get(id);
|
|
388
|
+
if (!sync)
|
|
389
|
+
return null;
|
|
390
|
+
return { ...sync };
|
|
391
|
+
},
|
|
392
|
+
async retrySync(id) {
|
|
393
|
+
await ensureInitialized();
|
|
394
|
+
const yjsDoc = yjsDocs.get(id);
|
|
395
|
+
if (!yjsDoc) {
|
|
396
|
+
throw new Error(`Document not found: ${id}`);
|
|
397
|
+
}
|
|
398
|
+
const existingSync = syncMetadataCache.get(id);
|
|
399
|
+
// Reset to local status to trigger re-sync
|
|
400
|
+
const syncMetadata = {
|
|
401
|
+
status: SyncStatus.Local,
|
|
402
|
+
lastSyncAttempt: existingSync?.lastSyncAttempt,
|
|
403
|
+
lastSynced: existingSync?.lastSynced,
|
|
404
|
+
retryCount: (existingSync?.retryCount ?? 0) + 1,
|
|
405
|
+
};
|
|
406
|
+
syncMetadataCache.set(id, syncMetadata);
|
|
407
|
+
// Persist to storage
|
|
408
|
+
await persistDocument(id, yjsDoc, syncMetadata);
|
|
409
|
+
// Notify subscribers
|
|
410
|
+
notifySubscribers();
|
|
411
|
+
},
|
|
412
|
+
async _markSynced(id) {
|
|
413
|
+
await ensureInitialized();
|
|
414
|
+
const yjsDoc = yjsDocs.get(id);
|
|
415
|
+
if (!yjsDoc)
|
|
416
|
+
return;
|
|
417
|
+
const syncMetadata = {
|
|
418
|
+
status: SyncStatus.Synced,
|
|
419
|
+
lastSyncAttempt: Date.now(),
|
|
420
|
+
lastSynced: Date.now(),
|
|
421
|
+
retryCount: 0,
|
|
422
|
+
};
|
|
423
|
+
syncMetadataCache.set(id, syncMetadata);
|
|
424
|
+
// Persist to storage
|
|
425
|
+
await persistDocument(id, yjsDoc, syncMetadata);
|
|
426
|
+
// Notify subscribers
|
|
427
|
+
notifySubscribers();
|
|
428
|
+
},
|
|
429
|
+
async _markRejected(id, error) {
|
|
430
|
+
await ensureInitialized();
|
|
431
|
+
const yjsDoc = yjsDocs.get(id);
|
|
432
|
+
if (!yjsDoc)
|
|
433
|
+
return;
|
|
434
|
+
const existingSync = syncMetadataCache.get(id);
|
|
435
|
+
const syncMetadata = {
|
|
436
|
+
status: SyncStatus.Rejected,
|
|
437
|
+
error,
|
|
438
|
+
lastSyncAttempt: Date.now(),
|
|
439
|
+
lastSynced: existingSync?.lastSynced,
|
|
440
|
+
retryCount: (existingSync?.retryCount ?? 0) + 1,
|
|
441
|
+
};
|
|
442
|
+
syncMetadataCache.set(id, syncMetadata);
|
|
443
|
+
// Persist to storage
|
|
444
|
+
await persistDocument(id, yjsDoc, syncMetadata);
|
|
445
|
+
// Notify subscribers
|
|
446
|
+
notifySubscribers();
|
|
447
|
+
// Emit rejected event
|
|
448
|
+
const doc = extractDocFromYjs(yjsDoc, id);
|
|
449
|
+
notifyRejected({
|
|
450
|
+
collection: name,
|
|
451
|
+
documentId: id,
|
|
452
|
+
document: doc,
|
|
453
|
+
error,
|
|
454
|
+
});
|
|
455
|
+
},
|
|
456
|
+
async _markSyncing(id) {
|
|
457
|
+
await ensureInitialized();
|
|
458
|
+
const yjsDoc = yjsDocs.get(id);
|
|
459
|
+
if (!yjsDoc)
|
|
460
|
+
return;
|
|
461
|
+
const existingSync = syncMetadataCache.get(id);
|
|
462
|
+
const syncMetadata = {
|
|
463
|
+
status: SyncStatus.Syncing,
|
|
464
|
+
lastSyncAttempt: Date.now(),
|
|
465
|
+
lastSynced: existingSync?.lastSynced,
|
|
466
|
+
retryCount: existingSync?.retryCount ?? 0,
|
|
467
|
+
};
|
|
468
|
+
syncMetadataCache.set(id, syncMetadata);
|
|
469
|
+
// Persist to storage
|
|
470
|
+
await persistDocument(id, yjsDoc, syncMetadata);
|
|
471
|
+
// Notify subscribers
|
|
472
|
+
notifySubscribers();
|
|
473
|
+
},
|
|
474
|
+
async getYjsUpdate(id) {
|
|
475
|
+
await ensureInitialized();
|
|
476
|
+
const yjsDoc = yjsDocs.get(id);
|
|
477
|
+
if (!yjsDoc)
|
|
478
|
+
return null;
|
|
479
|
+
return yjsDoc.encodeStateAsUpdate();
|
|
480
|
+
},
|
|
481
|
+
async getStateVector(id) {
|
|
482
|
+
await ensureInitialized();
|
|
483
|
+
const yjsDoc = yjsDocs.get(id);
|
|
484
|
+
if (!yjsDoc)
|
|
485
|
+
return null;
|
|
486
|
+
return yjsDoc.getStateVector();
|
|
487
|
+
},
|
|
488
|
+
async getDeltaUpdate(id, sinceStateVector) {
|
|
489
|
+
await ensureInitialized();
|
|
490
|
+
const yjsDoc = yjsDocs.get(id);
|
|
491
|
+
if (!yjsDoc)
|
|
492
|
+
return null;
|
|
493
|
+
return yjsDoc.encodeStateAsUpdate(sinceStateVector);
|
|
494
|
+
},
|
|
495
|
+
async getConflict(id) {
|
|
496
|
+
await ensureInitialized();
|
|
497
|
+
return conflicts.get(id) ?? null;
|
|
498
|
+
},
|
|
499
|
+
async resolveConflict(id, choice) {
|
|
500
|
+
await ensureInitialized();
|
|
501
|
+
const yjsDoc = yjsDocs.get(id);
|
|
502
|
+
if (!yjsDoc) {
|
|
503
|
+
throw new Error(`Document not found: ${id}`);
|
|
504
|
+
}
|
|
505
|
+
const conflict = conflicts.get(id);
|
|
506
|
+
if (!conflict) {
|
|
507
|
+
throw new Error(`No conflict exists for document: ${id}`);
|
|
508
|
+
}
|
|
509
|
+
// Apply the chosen version
|
|
510
|
+
const chosenVersion = choice === 'local' ? conflict.localVersion : conflict.serverVersion;
|
|
511
|
+
// Update all fields from chosen version
|
|
512
|
+
for (const [key, value] of Object.entries(chosenVersion)) {
|
|
513
|
+
if (key !== 'id') {
|
|
514
|
+
yjsDoc.set(key, value);
|
|
515
|
+
}
|
|
516
|
+
}
|
|
517
|
+
// Clear the conflict
|
|
518
|
+
conflicts.delete(id);
|
|
519
|
+
// Reset to local status for re-sync
|
|
520
|
+
const existingSync = syncMetadataCache.get(id);
|
|
521
|
+
const syncMetadata = {
|
|
522
|
+
status: SyncStatus.Local,
|
|
523
|
+
lastSyncAttempt: existingSync?.lastSyncAttempt,
|
|
524
|
+
lastSynced: existingSync?.lastSynced,
|
|
525
|
+
retryCount: 0,
|
|
526
|
+
};
|
|
527
|
+
syncMetadataCache.set(id, syncMetadata);
|
|
528
|
+
// Persist to storage
|
|
529
|
+
await persistDocument(id, yjsDoc, syncMetadata);
|
|
530
|
+
// Notify subscribers
|
|
531
|
+
notifySubscribers();
|
|
532
|
+
},
|
|
533
|
+
async keepBoth(id) {
|
|
534
|
+
await ensureInitialized();
|
|
535
|
+
const yjsDoc = yjsDocs.get(id);
|
|
536
|
+
if (!yjsDoc) {
|
|
537
|
+
throw new Error(`Document not found: ${id}`);
|
|
538
|
+
}
|
|
539
|
+
const conflict = conflicts.get(id);
|
|
540
|
+
if (!conflict) {
|
|
541
|
+
throw new Error(`No conflict exists for document: ${id}`);
|
|
542
|
+
}
|
|
543
|
+
// Keep local version in original document
|
|
544
|
+
for (const [key, value] of Object.entries(conflict.localVersion)) {
|
|
545
|
+
if (key !== 'id') {
|
|
546
|
+
yjsDoc.set(key, value);
|
|
547
|
+
}
|
|
548
|
+
}
|
|
549
|
+
// Clear the conflict for original
|
|
550
|
+
conflicts.delete(id);
|
|
551
|
+
// Update original document status
|
|
552
|
+
const existingSync = syncMetadataCache.get(id);
|
|
553
|
+
const syncMetadata = {
|
|
554
|
+
status: SyncStatus.Local,
|
|
555
|
+
lastSyncAttempt: existingSync?.lastSyncAttempt,
|
|
556
|
+
lastSynced: existingSync?.lastSynced,
|
|
557
|
+
retryCount: 0,
|
|
558
|
+
};
|
|
559
|
+
syncMetadataCache.set(id, syncMetadata);
|
|
560
|
+
await persistDocument(id, yjsDoc, syncMetadata);
|
|
561
|
+
// Create a new document with server version (excluding id)
|
|
562
|
+
const { id: _serverId, ...serverData } = conflict.serverVersion;
|
|
563
|
+
const copyDoc = await this.insert(serverData);
|
|
564
|
+
// Note: insert() already notifies subscribers
|
|
565
|
+
return copyDoc.id;
|
|
566
|
+
},
|
|
567
|
+
async hasConflict(id) {
|
|
568
|
+
await ensureInitialized();
|
|
569
|
+
return conflicts.has(id);
|
|
570
|
+
},
|
|
571
|
+
async listConflicts() {
|
|
572
|
+
await ensureInitialized();
|
|
573
|
+
return Array.from(conflicts.values());
|
|
574
|
+
},
|
|
575
|
+
onConflictDetected(callback) {
|
|
576
|
+
conflictSubscribers.add(callback);
|
|
577
|
+
return () => {
|
|
578
|
+
conflictSubscribers.delete(callback);
|
|
579
|
+
};
|
|
580
|
+
},
|
|
581
|
+
async _setConflict(id, conflict) {
|
|
582
|
+
await ensureInitialized();
|
|
583
|
+
const yjsDoc = yjsDocs.get(id);
|
|
584
|
+
if (!yjsDoc) {
|
|
585
|
+
throw new Error(`Document not found: ${id}`);
|
|
586
|
+
}
|
|
587
|
+
conflicts.set(id, conflict);
|
|
588
|
+
// Emit conflict detected event
|
|
589
|
+
notifyConflictDetected({
|
|
590
|
+
collection: name,
|
|
591
|
+
documentId: id,
|
|
592
|
+
localVersion: conflict.localVersion,
|
|
593
|
+
serverVersion: conflict.serverVersion,
|
|
594
|
+
detectedAt: conflict.detectedAt,
|
|
595
|
+
});
|
|
596
|
+
},
|
|
597
|
+
async applyUpdate(id, update, initialDoc) {
|
|
598
|
+
await ensureInitialized();
|
|
599
|
+
let yjsDoc = yjsDocs.get(id);
|
|
600
|
+
let isNewDoc = false;
|
|
601
|
+
if (!yjsDoc) {
|
|
602
|
+
// Create new YjsDocument for this ID
|
|
603
|
+
yjsDoc = new YjsDocument(id);
|
|
604
|
+
yjsDocs.set(id, yjsDoc);
|
|
605
|
+
syncMetadataCache.set(id, createLocalSyncMetadata());
|
|
606
|
+
isNewDoc = true;
|
|
607
|
+
}
|
|
608
|
+
// Apply the external Yjs update (CRDT merge) FIRST
|
|
609
|
+
// This is important - the update contains the authoritative CRDT state
|
|
610
|
+
yjsDoc.applyUpdate(update);
|
|
611
|
+
// Only set initialDoc fields if document is new AND they don't conflict
|
|
612
|
+
// with the applied update. This is for bootstrapping a document that
|
|
613
|
+
// will receive updates but needs some initial structure.
|
|
614
|
+
// In practice, if we have an update, it should contain all the state.
|
|
615
|
+
if (isNewDoc && initialDoc) {
|
|
616
|
+
const yDoc = yjsDoc.getYDoc();
|
|
617
|
+
const fields = yDoc.getMap('fields');
|
|
618
|
+
for (const [key, value] of Object.entries(initialDoc)) {
|
|
619
|
+
// Only set if not already present from the update
|
|
620
|
+
if (!fields.has(key)) {
|
|
621
|
+
yjsDoc.set(key, value);
|
|
622
|
+
}
|
|
623
|
+
}
|
|
624
|
+
}
|
|
625
|
+
// Get current sync metadata
|
|
626
|
+
const existingSync = syncMetadataCache.get(id);
|
|
627
|
+
const syncMetadata = isNewDoc ? existingSync : {
|
|
628
|
+
...existingSync,
|
|
629
|
+
// Keep status as-is, the merge doesn't change local sync status
|
|
630
|
+
};
|
|
631
|
+
// Persist to storage
|
|
632
|
+
await persistDocument(id, yjsDoc, syncMetadata);
|
|
633
|
+
// Notify subscribers
|
|
634
|
+
notifySubscribers();
|
|
635
|
+
},
|
|
636
|
+
// ============ Tombstone Methods ============
|
|
637
|
+
async getTombstone(id) {
|
|
638
|
+
await ensureInitialized();
|
|
639
|
+
return tombstones.get(id) ?? null;
|
|
640
|
+
},
|
|
641
|
+
async listTombstones() {
|
|
642
|
+
await ensureInitialized();
|
|
643
|
+
return Array.from(tombstones.values());
|
|
644
|
+
},
|
|
645
|
+
async getTombstoneDelta() {
|
|
646
|
+
await ensureInitialized();
|
|
647
|
+
const unsyncedTombstones = Array.from(tombstones.values()).filter(t => !t.synced);
|
|
648
|
+
return {
|
|
649
|
+
collection: name,
|
|
650
|
+
tombstones: unsyncedTombstones,
|
|
651
|
+
};
|
|
652
|
+
},
|
|
653
|
+
async applyTombstone(remoteTombstone) {
|
|
654
|
+
await ensureInitialized();
|
|
655
|
+
const id = remoteTombstone.documentId;
|
|
656
|
+
const existingTombstone = tombstones.get(id);
|
|
657
|
+
// If we already have a tombstone, use the newer one
|
|
658
|
+
if (existingTombstone) {
|
|
659
|
+
if (remoteTombstone.deletedAt > existingTombstone.deletedAt) {
|
|
660
|
+
const updatedTombstone = {
|
|
661
|
+
documentId: id,
|
|
662
|
+
deletedAt: remoteTombstone.deletedAt,
|
|
663
|
+
synced: true, // Remote tombstones are already synced
|
|
664
|
+
};
|
|
665
|
+
tombstones.set(id, updatedTombstone);
|
|
666
|
+
await persistTombstone(updatedTombstone);
|
|
667
|
+
}
|
|
668
|
+
return;
|
|
669
|
+
}
|
|
670
|
+
// Check if document exists and compare timestamps
|
|
671
|
+
const lastUpdate = lastUpdateTimestamps.get(id) ?? 0;
|
|
672
|
+
// Delete wins if tombstone is newer than last update
|
|
673
|
+
if (remoteTombstone.deletedAt >= lastUpdate) {
|
|
674
|
+
// Create tombstone
|
|
675
|
+
const newTombstone = {
|
|
676
|
+
documentId: id,
|
|
677
|
+
deletedAt: remoteTombstone.deletedAt,
|
|
678
|
+
synced: true, // Remote tombstones are already synced
|
|
679
|
+
};
|
|
680
|
+
tombstones.set(id, newTombstone);
|
|
681
|
+
await persistTombstone(newTombstone);
|
|
682
|
+
// Clean up document if it exists
|
|
683
|
+
const yjsDoc = yjsDocs.get(id);
|
|
684
|
+
if (yjsDoc) {
|
|
685
|
+
yjsDoc.destroy();
|
|
686
|
+
yjsDocs.delete(id);
|
|
687
|
+
}
|
|
688
|
+
syncMetadataCache.delete(id);
|
|
689
|
+
lastUpdateTimestamps.delete(id);
|
|
690
|
+
conflicts.delete(id);
|
|
691
|
+
// Delete from persistence
|
|
692
|
+
await persistence.delete(name, id);
|
|
693
|
+
// Notify subscribers
|
|
694
|
+
notifySubscribers();
|
|
695
|
+
}
|
|
696
|
+
},
|
|
697
|
+
async applyUpdateWithTimestamp(id, updates, timestamp) {
|
|
698
|
+
await ensureInitialized();
|
|
699
|
+
// Check if there's a newer tombstone
|
|
700
|
+
const tombstone = tombstones.get(id);
|
|
701
|
+
if (tombstone && tombstone.deletedAt > timestamp) {
|
|
702
|
+
// Tombstone is newer, don't resurrect the document
|
|
703
|
+
return;
|
|
704
|
+
}
|
|
705
|
+
// If there's an older tombstone, remove it (document is being resurrected)
|
|
706
|
+
if (tombstone && tombstone.deletedAt <= timestamp) {
|
|
707
|
+
tombstones.delete(id);
|
|
708
|
+
}
|
|
709
|
+
let yjsDoc = yjsDocs.get(id);
|
|
710
|
+
if (!yjsDoc) {
|
|
711
|
+
// Create new document
|
|
712
|
+
yjsDoc = new YjsDocument(id);
|
|
713
|
+
yjsDocs.set(id, yjsDoc);
|
|
714
|
+
syncMetadataCache.set(id, createLocalSyncMetadata());
|
|
715
|
+
}
|
|
716
|
+
// Apply updates
|
|
717
|
+
for (const [key, value] of Object.entries(updates)) {
|
|
718
|
+
yjsDoc.set(key, value);
|
|
719
|
+
}
|
|
720
|
+
// Track the update timestamp
|
|
721
|
+
lastUpdateTimestamps.set(id, timestamp);
|
|
722
|
+
// Update sync metadata
|
|
723
|
+
const existingSync = syncMetadataCache.get(id);
|
|
724
|
+
const syncMetadata = {
|
|
725
|
+
status: SyncStatus.Local,
|
|
726
|
+
lastSyncAttempt: existingSync?.lastSyncAttempt,
|
|
727
|
+
lastSynced: existingSync?.lastSynced,
|
|
728
|
+
retryCount: 0,
|
|
729
|
+
};
|
|
730
|
+
syncMetadataCache.set(id, syncMetadata);
|
|
731
|
+
// Persist to storage
|
|
732
|
+
await persistDocument(id, yjsDoc, syncMetadata);
|
|
733
|
+
// Notify subscribers
|
|
734
|
+
notifySubscribers();
|
|
735
|
+
},
|
|
736
|
+
async _markTombstoneSynced(id) {
|
|
737
|
+
await ensureInitialized();
|
|
738
|
+
const tombstone = tombstones.get(id);
|
|
739
|
+
if (tombstone) {
|
|
740
|
+
const updatedTombstone = {
|
|
741
|
+
...tombstone,
|
|
742
|
+
synced: true,
|
|
743
|
+
};
|
|
744
|
+
tombstones.set(id, updatedTombstone);
|
|
745
|
+
await persistTombstone(updatedTombstone);
|
|
746
|
+
}
|
|
747
|
+
},
|
|
748
|
+
getTombstoneRetention() {
|
|
749
|
+
return tombstoneRetention;
|
|
750
|
+
},
|
|
751
|
+
setTombstoneRetention(retention) {
|
|
752
|
+
tombstoneRetention = retention;
|
|
753
|
+
},
|
|
754
|
+
async cleanupTombstones() {
|
|
755
|
+
await ensureInitialized();
|
|
756
|
+
const now = Date.now();
|
|
757
|
+
const toDelete = [];
|
|
758
|
+
for (const [id, tombstone] of tombstones) {
|
|
759
|
+
// Only clean up tombstones that are:
|
|
760
|
+
// 1. Synced (so other edges know about the deletion)
|
|
761
|
+
// 2. Older than the retention period
|
|
762
|
+
if (tombstone.synced && (now - tombstone.deletedAt) > tombstoneRetention) {
|
|
763
|
+
toDelete.push(id);
|
|
764
|
+
}
|
|
765
|
+
}
|
|
766
|
+
for (const id of toDelete) {
|
|
767
|
+
tombstones.delete(id);
|
|
768
|
+
await deleteTombstoneFromPersistence(id);
|
|
769
|
+
}
|
|
770
|
+
},
|
|
771
|
+
async _clear() {
|
|
772
|
+
await ensureInitialized();
|
|
773
|
+
// Get all document IDs
|
|
774
|
+
const docIds = Array.from(yjsDocs.keys());
|
|
775
|
+
// Clean up all YjsDocuments
|
|
776
|
+
for (const yjsDoc of yjsDocs.values()) {
|
|
777
|
+
yjsDoc.destroy();
|
|
778
|
+
}
|
|
779
|
+
yjsDocs.clear();
|
|
780
|
+
syncMetadataCache.clear();
|
|
781
|
+
lastUpdateTimestamps.clear();
|
|
782
|
+
conflicts.clear();
|
|
783
|
+
tombstones.clear();
|
|
784
|
+
documentPriorities.clear();
|
|
785
|
+
// Delete from persistence
|
|
786
|
+
for (const docId of docIds) {
|
|
787
|
+
await persistence.delete(name, docId);
|
|
788
|
+
}
|
|
789
|
+
// Delete all tombstones from persistence
|
|
790
|
+
const tombstoneIds = await persistence.list(TOMBSTONE_COLLECTION);
|
|
791
|
+
for (const tombstoneId of tombstoneIds) {
|
|
792
|
+
await persistence.delete(TOMBSTONE_COLLECTION, tombstoneId);
|
|
793
|
+
}
|
|
794
|
+
// Notify subscribers that collection is now empty
|
|
795
|
+
notifySubscribers();
|
|
796
|
+
},
|
|
797
|
+
async getPriority(id) {
|
|
798
|
+
await ensureInitialized();
|
|
799
|
+
const yjsDoc = yjsDocs.get(id);
|
|
800
|
+
if (!yjsDoc)
|
|
801
|
+
return null;
|
|
802
|
+
return documentPriorities.get(id) ?? SyncPriorityEnum.Normal;
|
|
803
|
+
},
|
|
804
|
+
async setPriority(id, priority) {
|
|
805
|
+
await ensureInitialized();
|
|
806
|
+
const yjsDoc = yjsDocs.get(id);
|
|
807
|
+
if (!yjsDoc) {
|
|
808
|
+
throw new Error(`Document not found: ${id}`);
|
|
809
|
+
}
|
|
810
|
+
documentPriorities.set(id, priority);
|
|
811
|
+
},
|
|
812
|
+
async setCollectionPriority(priority) {
|
|
813
|
+
await ensureInitialized();
|
|
814
|
+
for (const id of yjsDocs.keys()) {
|
|
815
|
+
documentPriorities.set(id, priority);
|
|
816
|
+
}
|
|
817
|
+
},
|
|
818
|
+
async query(options) {
|
|
819
|
+
await ensureInitialized();
|
|
820
|
+
// Start with all documents
|
|
821
|
+
let results = getSyncedDocs();
|
|
822
|
+
// Apply filters
|
|
823
|
+
if (options.filter) {
|
|
824
|
+
results = results.filter(doc => {
|
|
825
|
+
for (const [field, operators] of Object.entries(options.filter)) {
|
|
826
|
+
const fieldValue = doc[field];
|
|
827
|
+
const ops = operators;
|
|
828
|
+
// Check each operator
|
|
829
|
+
if (ops.eq !== undefined && fieldValue !== ops.eq) {
|
|
830
|
+
return false;
|
|
831
|
+
}
|
|
832
|
+
if (ops.ne !== undefined && fieldValue === ops.ne) {
|
|
833
|
+
return false;
|
|
834
|
+
}
|
|
835
|
+
if (ops.gt !== undefined) {
|
|
836
|
+
if (fieldValue === undefined || fieldValue === null)
|
|
837
|
+
return false;
|
|
838
|
+
if (fieldValue <= ops.gt)
|
|
839
|
+
return false;
|
|
840
|
+
}
|
|
841
|
+
if (ops.lt !== undefined) {
|
|
842
|
+
if (fieldValue === undefined || fieldValue === null)
|
|
843
|
+
return false;
|
|
844
|
+
if (fieldValue >= ops.lt)
|
|
845
|
+
return false;
|
|
846
|
+
}
|
|
847
|
+
if (ops.gte !== undefined) {
|
|
848
|
+
if (fieldValue === undefined || fieldValue === null)
|
|
849
|
+
return false;
|
|
850
|
+
if (fieldValue < ops.gte)
|
|
851
|
+
return false;
|
|
852
|
+
}
|
|
853
|
+
if (ops.lte !== undefined) {
|
|
854
|
+
if (fieldValue === undefined || fieldValue === null)
|
|
855
|
+
return false;
|
|
856
|
+
if (fieldValue > ops.lte)
|
|
857
|
+
return false;
|
|
858
|
+
}
|
|
859
|
+
if (ops.in !== undefined) {
|
|
860
|
+
if (!Array.isArray(ops.in))
|
|
861
|
+
return false;
|
|
862
|
+
if (ops.in.length === 0)
|
|
863
|
+
return false;
|
|
864
|
+
if (!ops.in.includes(fieldValue))
|
|
865
|
+
return false;
|
|
866
|
+
}
|
|
867
|
+
if (ops.notIn !== undefined) {
|
|
868
|
+
if (!Array.isArray(ops.notIn))
|
|
869
|
+
return false;
|
|
870
|
+
if (ops.notIn.length > 0 && ops.notIn.includes(fieldValue))
|
|
871
|
+
return false;
|
|
872
|
+
}
|
|
873
|
+
if (ops.contains !== undefined) {
|
|
874
|
+
if (!Array.isArray(fieldValue))
|
|
875
|
+
return false;
|
|
876
|
+
if (!fieldValue.includes(ops.contains))
|
|
877
|
+
return false;
|
|
878
|
+
}
|
|
879
|
+
}
|
|
880
|
+
return true;
|
|
881
|
+
});
|
|
882
|
+
}
|
|
883
|
+
// Apply sorting
|
|
884
|
+
if (options.orderBy) {
|
|
885
|
+
const { field, direction = 'asc' } = options.orderBy;
|
|
886
|
+
results = results.sort((a, b) => {
|
|
887
|
+
const aVal = a[field];
|
|
888
|
+
const bVal = b[field];
|
|
889
|
+
// Handle undefined/null values
|
|
890
|
+
if (aVal === undefined || aVal === null)
|
|
891
|
+
return direction === 'asc' ? 1 : -1;
|
|
892
|
+
if (bVal === undefined || bVal === null)
|
|
893
|
+
return direction === 'asc' ? -1 : 1;
|
|
894
|
+
// Compare values
|
|
895
|
+
let comparison = 0;
|
|
896
|
+
if (typeof aVal === 'string' && typeof bVal === 'string') {
|
|
897
|
+
comparison = aVal.localeCompare(bVal);
|
|
898
|
+
}
|
|
899
|
+
else if (typeof aVal === 'number' && typeof bVal === 'number') {
|
|
900
|
+
comparison = aVal - bVal;
|
|
901
|
+
}
|
|
902
|
+
else if (typeof aVal === 'boolean' && typeof bVal === 'boolean') {
|
|
903
|
+
comparison = (aVal === bVal) ? 0 : (aVal ? 1 : -1);
|
|
904
|
+
}
|
|
905
|
+
else {
|
|
906
|
+
comparison = String(aVal).localeCompare(String(bVal));
|
|
907
|
+
}
|
|
908
|
+
return direction === 'desc' ? -comparison : comparison;
|
|
909
|
+
});
|
|
910
|
+
}
|
|
911
|
+
// Apply pagination
|
|
912
|
+
const offset = options.offset ?? 0;
|
|
913
|
+
const limit = options.limit;
|
|
914
|
+
if (offset > 0) {
|
|
915
|
+
results = results.slice(offset);
|
|
916
|
+
}
|
|
917
|
+
if (limit !== undefined && limit >= 0) {
|
|
918
|
+
results = results.slice(0, limit);
|
|
919
|
+
}
|
|
920
|
+
return results;
|
|
921
|
+
},
|
|
922
|
+
};
|
|
923
|
+
}
|
|
924
|
+
//# sourceMappingURL=collection.js.map
|