@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.
Files changed (186) hide show
  1. package/README.md +312 -0
  2. package/dist/client/attachment.d.ts +225 -0
  3. package/dist/client/attachment.d.ts.map +1 -0
  4. package/dist/client/attachment.js +402 -0
  5. package/dist/client/attachment.js.map +1 -0
  6. package/dist/client/binary-encoding.d.ts +45 -0
  7. package/dist/client/binary-encoding.d.ts.map +1 -0
  8. package/dist/client/binary-encoding.js +90 -0
  9. package/dist/client/binary-encoding.js.map +1 -0
  10. package/dist/client/collection.d.ts +10 -0
  11. package/dist/client/collection.d.ts.map +1 -0
  12. package/dist/client/collection.js +924 -0
  13. package/dist/client/collection.js.map +1 -0
  14. package/dist/client/compression.d.ts +56 -0
  15. package/dist/client/compression.d.ts.map +1 -0
  16. package/dist/client/compression.js +173 -0
  17. package/dist/client/compression.js.map +1 -0
  18. package/dist/client/crdt/index.d.ts +2 -0
  19. package/dist/client/crdt/index.d.ts.map +1 -0
  20. package/dist/client/crdt/index.js +2 -0
  21. package/dist/client/crdt/index.js.map +1 -0
  22. package/dist/client/crdt/yjs-doc.d.ts +88 -0
  23. package/dist/client/crdt/yjs-doc.d.ts.map +1 -0
  24. package/dist/client/crdt/yjs-doc.js +123 -0
  25. package/dist/client/crdt/yjs-doc.js.map +1 -0
  26. package/dist/client/index.d.ts +66 -0
  27. package/dist/client/index.d.ts.map +1 -0
  28. package/dist/client/index.js +233 -0
  29. package/dist/client/index.js.map +1 -0
  30. package/dist/client/mock-transport.d.ts +155 -0
  31. package/dist/client/mock-transport.d.ts.map +1 -0
  32. package/dist/client/mock-transport.js +292 -0
  33. package/dist/client/mock-transport.js.map +1 -0
  34. package/dist/client/network-detector.d.ts +65 -0
  35. package/dist/client/network-detector.d.ts.map +1 -0
  36. package/dist/client/network-detector.js +147 -0
  37. package/dist/client/network-detector.js.map +1 -0
  38. package/dist/client/provisioning.d.ts +126 -0
  39. package/dist/client/provisioning.d.ts.map +1 -0
  40. package/dist/client/provisioning.js +125 -0
  41. package/dist/client/provisioning.js.map +1 -0
  42. package/dist/client/signal.d.ts +13 -0
  43. package/dist/client/signal.d.ts.map +1 -0
  44. package/dist/client/signal.js +27 -0
  45. package/dist/client/signal.js.map +1 -0
  46. package/dist/client/sync-engine.d.ts +298 -0
  47. package/dist/client/sync-engine.d.ts.map +1 -0
  48. package/dist/client/sync-engine.js +904 -0
  49. package/dist/client/sync-engine.js.map +1 -0
  50. package/dist/client/synced-edge.d.ts +109 -0
  51. package/dist/client/synced-edge.d.ts.map +1 -0
  52. package/dist/client/synced-edge.js +179 -0
  53. package/dist/client/synced-edge.js.map +1 -0
  54. package/dist/client/synced-offline-edge-types.d.ts +540 -0
  55. package/dist/client/synced-offline-edge-types.d.ts.map +1 -0
  56. package/dist/client/synced-offline-edge-types.js +10 -0
  57. package/dist/client/synced-offline-edge-types.js.map +1 -0
  58. package/dist/client/synced-offline-edge.d.ts +54 -0
  59. package/dist/client/synced-offline-edge.d.ts.map +1 -0
  60. package/dist/client/synced-offline-edge.js +731 -0
  61. package/dist/client/synced-offline-edge.js.map +1 -0
  62. package/dist/client/transport.d.ts +202 -0
  63. package/dist/client/transport.d.ts.map +1 -0
  64. package/dist/client/transport.js +409 -0
  65. package/dist/client/transport.js.map +1 -0
  66. package/dist/client/types.d.ts +622 -0
  67. package/dist/client/types.d.ts.map +1 -0
  68. package/dist/client/types.js +60 -0
  69. package/dist/client/types.js.map +1 -0
  70. package/dist/client/validation.d.ts +61 -0
  71. package/dist/client/validation.d.ts.map +1 -0
  72. package/dist/client/validation.js +57 -0
  73. package/dist/client/validation.js.map +1 -0
  74. package/dist/client/versioning.d.ts +134 -0
  75. package/dist/client/versioning.d.ts.map +1 -0
  76. package/dist/client/versioning.js +304 -0
  77. package/dist/client/versioning.js.map +1 -0
  78. package/dist/index.d.ts +40 -0
  79. package/dist/index.d.ts.map +1 -0
  80. package/dist/index.js +51 -0
  81. package/dist/index.js.map +1 -0
  82. package/dist/persistence/encryption.d.ts +114 -0
  83. package/dist/persistence/encryption.d.ts.map +1 -0
  84. package/dist/persistence/encryption.js +286 -0
  85. package/dist/persistence/encryption.js.map +1 -0
  86. package/dist/persistence/index.d.ts +21 -0
  87. package/dist/persistence/index.d.ts.map +1 -0
  88. package/dist/persistence/index.js +20 -0
  89. package/dist/persistence/index.js.map +1 -0
  90. package/dist/persistence/memory.d.ts +32 -0
  91. package/dist/persistence/memory.d.ts.map +1 -0
  92. package/dist/persistence/memory.js +57 -0
  93. package/dist/persistence/memory.js.map +1 -0
  94. package/dist/persistence/migrations.d.ts +106 -0
  95. package/dist/persistence/migrations.d.ts.map +1 -0
  96. package/dist/persistence/migrations.js +176 -0
  97. package/dist/persistence/migrations.js.map +1 -0
  98. package/dist/persistence/pending-queue.d.ts +109 -0
  99. package/dist/persistence/pending-queue.d.ts.map +1 -0
  100. package/dist/persistence/pending-queue.js +249 -0
  101. package/dist/persistence/pending-queue.js.map +1 -0
  102. package/dist/persistence/pglite.d.ts +72 -0
  103. package/dist/persistence/pglite.d.ts.map +1 -0
  104. package/dist/persistence/pglite.js +126 -0
  105. package/dist/persistence/pglite.js.map +1 -0
  106. package/dist/persistence/quota-manager.d.ts +134 -0
  107. package/dist/persistence/quota-manager.d.ts.map +1 -0
  108. package/dist/persistence/quota-manager.js +242 -0
  109. package/dist/persistence/quota-manager.js.map +1 -0
  110. package/dist/persistence/types.d.ts +54 -0
  111. package/dist/persistence/types.d.ts.map +1 -0
  112. package/dist/persistence/types.js +2 -0
  113. package/dist/persistence/types.js.map +1 -0
  114. package/dist/react/OfflineEdgeProvider.d.ts +91 -0
  115. package/dist/react/OfflineEdgeProvider.d.ts.map +1 -0
  116. package/dist/react/OfflineEdgeProvider.js +127 -0
  117. package/dist/react/OfflineEdgeProvider.js.map +1 -0
  118. package/dist/react/SyncedOfflineEdgeProvider.d.ts +105 -0
  119. package/dist/react/SyncedOfflineEdgeProvider.d.ts.map +1 -0
  120. package/dist/react/SyncedOfflineEdgeProvider.js +138 -0
  121. package/dist/react/SyncedOfflineEdgeProvider.js.map +1 -0
  122. package/dist/react/index.d.ts +50 -0
  123. package/dist/react/index.d.ts.map +1 -0
  124. package/dist/react/index.js +51 -0
  125. package/dist/react/index.js.map +1 -0
  126. package/dist/react/useCollection.d.ts +77 -0
  127. package/dist/react/useCollection.d.ts.map +1 -0
  128. package/dist/react/useCollection.js +113 -0
  129. package/dist/react/useCollection.js.map +1 -0
  130. package/dist/react/useCollectionSyncMode.d.ts +61 -0
  131. package/dist/react/useCollectionSyncMode.d.ts.map +1 -0
  132. package/dist/react/useCollectionSyncMode.js +93 -0
  133. package/dist/react/useCollectionSyncMode.js.map +1 -0
  134. package/dist/react/useConnectionState.d.ts +44 -0
  135. package/dist/react/useConnectionState.d.ts.map +1 -0
  136. package/dist/react/useConnectionState.js +46 -0
  137. package/dist/react/useConnectionState.js.map +1 -0
  138. package/dist/react/useDocumentSyncStatus.d.ts +72 -0
  139. package/dist/react/useDocumentSyncStatus.d.ts.map +1 -0
  140. package/dist/react/useDocumentSyncStatus.js +110 -0
  141. package/dist/react/useDocumentSyncStatus.js.map +1 -0
  142. package/dist/react/useOfflineEdge.d.ts +58 -0
  143. package/dist/react/useOfflineEdge.d.ts.map +1 -0
  144. package/dist/react/useOfflineEdge.js +54 -0
  145. package/dist/react/useOfflineEdge.js.map +1 -0
  146. package/dist/react/usePendingChanges.d.ts +67 -0
  147. package/dist/react/usePendingChanges.d.ts.map +1 -0
  148. package/dist/react/usePendingChanges.js +90 -0
  149. package/dist/react/usePendingChanges.js.map +1 -0
  150. package/dist/react/useRejectedDocuments.d.ts +112 -0
  151. package/dist/react/useRejectedDocuments.d.ts.map +1 -0
  152. package/dist/react/useRejectedDocuments.js +213 -0
  153. package/dist/react/useRejectedDocuments.js.map +1 -0
  154. package/dist/react/useSyncControls.d.ts +96 -0
  155. package/dist/react/useSyncControls.d.ts.map +1 -0
  156. package/dist/react/useSyncControls.js +112 -0
  157. package/dist/react/useSyncControls.js.map +1 -0
  158. package/dist/react/useSyncProgress.d.ts +78 -0
  159. package/dist/react/useSyncProgress.d.ts.map +1 -0
  160. package/dist/react/useSyncProgress.js +90 -0
  161. package/dist/react/useSyncProgress.js.map +1 -0
  162. package/dist/react/useSyncRejected.d.ts +47 -0
  163. package/dist/react/useSyncRejected.d.ts.map +1 -0
  164. package/dist/react/useSyncRejected.js +55 -0
  165. package/dist/react/useSyncRejected.js.map +1 -0
  166. package/dist/react/useSyncStatus.d.ts +56 -0
  167. package/dist/react/useSyncStatus.d.ts.map +1 -0
  168. package/dist/react/useSyncStatus.js +59 -0
  169. package/dist/react/useSyncStatus.js.map +1 -0
  170. package/dist/react/useSyncedOfflineEdge.d.ts +69 -0
  171. package/dist/react/useSyncedOfflineEdge.d.ts.map +1 -0
  172. package/dist/react/useSyncedOfflineEdge.js +65 -0
  173. package/dist/react/useSyncedOfflineEdge.js.map +1 -0
  174. package/dist/service-worker/index.d.ts +7 -0
  175. package/dist/service-worker/index.d.ts.map +1 -0
  176. package/dist/service-worker/index.js +7 -0
  177. package/dist/service-worker/index.js.map +1 -0
  178. package/dist/service-worker/sync-worker.d.ts +230 -0
  179. package/dist/service-worker/sync-worker.d.ts.map +1 -0
  180. package/dist/service-worker/sync-worker.js +471 -0
  181. package/dist/service-worker/sync-worker.js.map +1 -0
  182. package/dist/types.d.ts +6 -0
  183. package/dist/types.d.ts.map +1 -0
  184. package/dist/types.js +3 -0
  185. package/dist/types.js.map +1 -0
  186. 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