cojson 0.19.22 → 0.20.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 (194) hide show
  1. package/.turbo/turbo-build.log +1 -1
  2. package/CHANGELOG.md +54 -0
  3. package/dist/coValueContentMessage.d.ts +0 -2
  4. package/dist/coValueContentMessage.d.ts.map +1 -1
  5. package/dist/coValueContentMessage.js +0 -8
  6. package/dist/coValueContentMessage.js.map +1 -1
  7. package/dist/coValueCore/SessionMap.d.ts +4 -2
  8. package/dist/coValueCore/SessionMap.d.ts.map +1 -1
  9. package/dist/coValueCore/SessionMap.js +30 -0
  10. package/dist/coValueCore/SessionMap.js.map +1 -1
  11. package/dist/coValueCore/coValueCore.d.ts +67 -3
  12. package/dist/coValueCore/coValueCore.d.ts.map +1 -1
  13. package/dist/coValueCore/coValueCore.js +289 -12
  14. package/dist/coValueCore/coValueCore.js.map +1 -1
  15. package/dist/coValueCore/verifiedState.d.ts +6 -1
  16. package/dist/coValueCore/verifiedState.d.ts.map +1 -1
  17. package/dist/coValueCore/verifiedState.js +9 -0
  18. package/dist/coValueCore/verifiedState.js.map +1 -1
  19. package/dist/coValues/coList.d.ts +3 -2
  20. package/dist/coValues/coList.d.ts.map +1 -1
  21. package/dist/coValues/coList.js.map +1 -1
  22. package/dist/coValues/group.d.ts.map +1 -1
  23. package/dist/coValues/group.js +3 -6
  24. package/dist/coValues/group.js.map +1 -1
  25. package/dist/config.d.ts +0 -6
  26. package/dist/config.d.ts.map +1 -1
  27. package/dist/config.js +0 -8
  28. package/dist/config.js.map +1 -1
  29. package/dist/crypto/NapiCrypto.d.ts +1 -2
  30. package/dist/crypto/NapiCrypto.d.ts.map +1 -1
  31. package/dist/crypto/NapiCrypto.js +19 -4
  32. package/dist/crypto/NapiCrypto.js.map +1 -1
  33. package/dist/crypto/RNCrypto.d.ts.map +1 -1
  34. package/dist/crypto/RNCrypto.js +19 -4
  35. package/dist/crypto/RNCrypto.js.map +1 -1
  36. package/dist/crypto/WasmCrypto.d.ts +11 -4
  37. package/dist/crypto/WasmCrypto.d.ts.map +1 -1
  38. package/dist/crypto/WasmCrypto.js +52 -10
  39. package/dist/crypto/WasmCrypto.js.map +1 -1
  40. package/dist/crypto/WasmCryptoEdge.d.ts +1 -0
  41. package/dist/crypto/WasmCryptoEdge.d.ts.map +1 -1
  42. package/dist/crypto/WasmCryptoEdge.js +4 -1
  43. package/dist/crypto/WasmCryptoEdge.js.map +1 -1
  44. package/dist/crypto/crypto.d.ts +3 -3
  45. package/dist/crypto/crypto.d.ts.map +1 -1
  46. package/dist/crypto/crypto.js +6 -1
  47. package/dist/crypto/crypto.js.map +1 -1
  48. package/dist/exports.d.ts +2 -2
  49. package/dist/exports.d.ts.map +1 -1
  50. package/dist/exports.js +2 -1
  51. package/dist/exports.js.map +1 -1
  52. package/dist/ids.d.ts +4 -1
  53. package/dist/ids.d.ts.map +1 -1
  54. package/dist/ids.js +4 -0
  55. package/dist/ids.js.map +1 -1
  56. package/dist/knownState.d.ts +2 -0
  57. package/dist/knownState.d.ts.map +1 -1
  58. package/dist/localNode.d.ts +12 -0
  59. package/dist/localNode.d.ts.map +1 -1
  60. package/dist/localNode.js +14 -0
  61. package/dist/localNode.js.map +1 -1
  62. package/dist/platformUtils.d.ts +3 -0
  63. package/dist/platformUtils.d.ts.map +1 -0
  64. package/dist/platformUtils.js +24 -0
  65. package/dist/platformUtils.js.map +1 -0
  66. package/dist/storage/DeletedCoValuesEraserScheduler.d.ts +30 -0
  67. package/dist/storage/DeletedCoValuesEraserScheduler.d.ts.map +1 -0
  68. package/dist/storage/DeletedCoValuesEraserScheduler.js +84 -0
  69. package/dist/storage/DeletedCoValuesEraserScheduler.js.map +1 -0
  70. package/dist/storage/sqlite/client.d.ts +3 -0
  71. package/dist/storage/sqlite/client.d.ts.map +1 -1
  72. package/dist/storage/sqlite/client.js +44 -0
  73. package/dist/storage/sqlite/client.js.map +1 -1
  74. package/dist/storage/sqlite/sqliteMigrations.d.ts.map +1 -1
  75. package/dist/storage/sqlite/sqliteMigrations.js +7 -0
  76. package/dist/storage/sqlite/sqliteMigrations.js.map +1 -1
  77. package/dist/storage/sqliteAsync/client.d.ts +3 -0
  78. package/dist/storage/sqliteAsync/client.d.ts.map +1 -1
  79. package/dist/storage/sqliteAsync/client.js +42 -0
  80. package/dist/storage/sqliteAsync/client.js.map +1 -1
  81. package/dist/storage/storageAsync.d.ts +7 -0
  82. package/dist/storage/storageAsync.d.ts.map +1 -1
  83. package/dist/storage/storageAsync.js +48 -0
  84. package/dist/storage/storageAsync.js.map +1 -1
  85. package/dist/storage/storageSync.d.ts +6 -0
  86. package/dist/storage/storageSync.d.ts.map +1 -1
  87. package/dist/storage/storageSync.js +42 -0
  88. package/dist/storage/storageSync.js.map +1 -1
  89. package/dist/storage/types.d.ts +59 -0
  90. package/dist/storage/types.d.ts.map +1 -1
  91. package/dist/storage/types.js +12 -1
  92. package/dist/storage/types.js.map +1 -1
  93. package/dist/sync.d.ts.map +1 -1
  94. package/dist/sync.js +44 -11
  95. package/dist/sync.js.map +1 -1
  96. package/dist/tests/DeletedCoValuesEraserScheduler.test.d.ts +2 -0
  97. package/dist/tests/DeletedCoValuesEraserScheduler.test.d.ts.map +1 -0
  98. package/dist/tests/DeletedCoValuesEraserScheduler.test.js +149 -0
  99. package/dist/tests/DeletedCoValuesEraserScheduler.test.js.map +1 -0
  100. package/dist/tests/GarbageCollector.test.js +5 -6
  101. package/dist/tests/GarbageCollector.test.js.map +1 -1
  102. package/dist/tests/StorageApiAsync.test.js +484 -152
  103. package/dist/tests/StorageApiAsync.test.js.map +1 -1
  104. package/dist/tests/StorageApiSync.test.js +505 -136
  105. package/dist/tests/StorageApiSync.test.js.map +1 -1
  106. package/dist/tests/WasmCrypto.test.js +6 -3
  107. package/dist/tests/WasmCrypto.test.js.map +1 -1
  108. package/dist/tests/coValueCore.loadFromStorage.test.js +3 -0
  109. package/dist/tests/coValueCore.loadFromStorage.test.js.map +1 -1
  110. package/dist/tests/coValueCore.test.js +34 -13
  111. package/dist/tests/coValueCore.test.js.map +1 -1
  112. package/dist/tests/coreWasm.test.js +127 -4
  113. package/dist/tests/coreWasm.test.js.map +1 -1
  114. package/dist/tests/crypto.test.js +89 -93
  115. package/dist/tests/crypto.test.js.map +1 -1
  116. package/dist/tests/deleteCoValue.test.d.ts +2 -0
  117. package/dist/tests/deleteCoValue.test.d.ts.map +1 -0
  118. package/dist/tests/deleteCoValue.test.js +313 -0
  119. package/dist/tests/deleteCoValue.test.js.map +1 -0
  120. package/dist/tests/group.removeMember.test.js +18 -30
  121. package/dist/tests/group.removeMember.test.js.map +1 -1
  122. package/dist/tests/knownState.lazyLoading.test.js +3 -0
  123. package/dist/tests/knownState.lazyLoading.test.js.map +1 -1
  124. package/dist/tests/sync.deleted.test.d.ts +2 -0
  125. package/dist/tests/sync.deleted.test.d.ts.map +1 -0
  126. package/dist/tests/sync.deleted.test.js +214 -0
  127. package/dist/tests/sync.deleted.test.js.map +1 -0
  128. package/dist/tests/sync.mesh.test.js +3 -2
  129. package/dist/tests/sync.mesh.test.js.map +1 -1
  130. package/dist/tests/sync.storage.test.js +3 -2
  131. package/dist/tests/sync.storage.test.js.map +1 -1
  132. package/dist/tests/sync.test.js +3 -2
  133. package/dist/tests/sync.test.js.map +1 -1
  134. package/dist/tests/testStorage.d.ts +3 -0
  135. package/dist/tests/testStorage.d.ts.map +1 -1
  136. package/dist/tests/testStorage.js +14 -0
  137. package/dist/tests/testStorage.js.map +1 -1
  138. package/dist/tests/testUtils.d.ts +6 -3
  139. package/dist/tests/testUtils.d.ts.map +1 -1
  140. package/dist/tests/testUtils.js +17 -3
  141. package/dist/tests/testUtils.js.map +1 -1
  142. package/package.json +6 -16
  143. package/src/coValueContentMessage.ts +0 -14
  144. package/src/coValueCore/SessionMap.ts +43 -1
  145. package/src/coValueCore/coValueCore.ts +400 -8
  146. package/src/coValueCore/verifiedState.ts +26 -3
  147. package/src/coValues/coList.ts +5 -3
  148. package/src/coValues/group.ts +5 -6
  149. package/src/config.ts +0 -9
  150. package/src/crypto/NapiCrypto.ts +29 -13
  151. package/src/crypto/RNCrypto.ts +29 -11
  152. package/src/crypto/WasmCrypto.ts +67 -20
  153. package/src/crypto/WasmCryptoEdge.ts +5 -1
  154. package/src/crypto/crypto.ts +16 -4
  155. package/src/exports.ts +2 -0
  156. package/src/ids.ts +11 -1
  157. package/src/localNode.ts +15 -0
  158. package/src/platformUtils.ts +26 -0
  159. package/src/storage/DeletedCoValuesEraserScheduler.ts +124 -0
  160. package/src/storage/sqlite/client.ts +77 -0
  161. package/src/storage/sqlite/sqliteMigrations.ts +7 -0
  162. package/src/storage/sqliteAsync/client.ts +75 -0
  163. package/src/storage/storageAsync.ts +62 -0
  164. package/src/storage/storageSync.ts +58 -0
  165. package/src/storage/types.ts +69 -0
  166. package/src/sync.ts +51 -11
  167. package/src/tests/DeletedCoValuesEraserScheduler.test.ts +185 -0
  168. package/src/tests/GarbageCollector.test.ts +6 -10
  169. package/src/tests/StorageApiAsync.test.ts +572 -162
  170. package/src/tests/StorageApiSync.test.ts +580 -143
  171. package/src/tests/WasmCrypto.test.ts +8 -3
  172. package/src/tests/coValueCore.loadFromStorage.test.ts +6 -0
  173. package/src/tests/coValueCore.test.ts +49 -14
  174. package/src/tests/coreWasm.test.ts +319 -10
  175. package/src/tests/crypto.test.ts +141 -150
  176. package/src/tests/deleteCoValue.test.ts +528 -0
  177. package/src/tests/group.removeMember.test.ts +35 -35
  178. package/src/tests/knownState.lazyLoading.test.ts +6 -0
  179. package/src/tests/sync.deleted.test.ts +294 -0
  180. package/src/tests/sync.mesh.test.ts +5 -2
  181. package/src/tests/sync.storage.test.ts +5 -2
  182. package/src/tests/sync.test.ts +5 -2
  183. package/src/tests/testStorage.ts +28 -1
  184. package/src/tests/testUtils.ts +28 -9
  185. package/dist/crypto/PureJSCrypto.d.ts +0 -77
  186. package/dist/crypto/PureJSCrypto.d.ts.map +0 -1
  187. package/dist/crypto/PureJSCrypto.js +0 -236
  188. package/dist/crypto/PureJSCrypto.js.map +0 -1
  189. package/dist/tests/PureJSCrypto.test.d.ts +0 -2
  190. package/dist/tests/PureJSCrypto.test.d.ts.map +0 -1
  191. package/dist/tests/PureJSCrypto.test.js +0 -145
  192. package/dist/tests/PureJSCrypto.test.js.map +0 -1
  193. package/src/crypto/PureJSCrypto.ts +0 -429
  194. package/src/tests/PureJSCrypto.test.ts +0 -217
@@ -40,6 +40,13 @@ export const migrations: Record<number, string[]> = {
40
40
  );`,
41
41
  "CREATE INDEX IF NOT EXISTS idx_unsynced_covalues_co_value_id ON unsynced_covalues(co_value_id);",
42
42
  ],
43
+ 5: [
44
+ `CREATE TABLE IF NOT EXISTS deletedCoValues (
45
+ coValueID TEXT PRIMARY KEY,
46
+ status INTEGER NOT NULL DEFAULT 0
47
+ ) WITHOUT ROWID;`,
48
+ "CREATE INDEX IF NOT EXISTS deletedCoValuesByStatus ON deletedCoValues (status);",
49
+ ],
43
50
  };
44
51
 
45
52
  type Migration = {
@@ -15,6 +15,7 @@ import type {
15
15
  StoredSessionRow,
16
16
  TransactionRow,
17
17
  } from "../types.js";
18
+ import { DeletedCoValueDeletionStatus } from "../types.js";
18
19
  import type { SQLiteDatabaseDriverAsync } from "./types.js";
19
20
  import type { PeerID } from "../../sync.js";
20
21
 
@@ -29,6 +30,10 @@ export type RawTransactionRow = {
29
30
  tx: string;
30
31
  };
31
32
 
33
+ type DeletedCoValueQueueRow = {
34
+ id: RawCoID;
35
+ };
36
+
32
37
  export function getErrorMessage(error: unknown) {
33
38
  return error instanceof Error ? error.message : "Unknown error";
34
39
  }
@@ -146,6 +151,76 @@ export class SQLiteClientAsync
146
151
  return result.rowID;
147
152
  }
148
153
 
154
+ async markCoValueAsDeleted(id: RawCoID) {
155
+ // Work queue entry. Table only stores the coValueID.
156
+ // Idempotent by design.
157
+ await this.db.run(
158
+ `INSERT INTO deletedCoValues (coValueID) VALUES (?) ON CONFLICT(coValueID) DO NOTHING`,
159
+ [id],
160
+ );
161
+ }
162
+
163
+ async eraseCoValueButKeepTombstone(coValueId: RawCoID) {
164
+ const coValueRow = await this.db.get<RawCoValueRow & { rowID: number }>(
165
+ "SELECT * FROM coValues WHERE id = ?",
166
+ [coValueId],
167
+ );
168
+
169
+ if (!coValueRow) {
170
+ logger.warn(`CoValue ${coValueId} not found, skipping deletion`);
171
+ return;
172
+ }
173
+
174
+ await this.transaction(async () => {
175
+ await this.db.run(
176
+ `DELETE FROM transactions
177
+ WHERE ses IN (
178
+ SELECT rowID FROM sessions
179
+ WHERE coValue = ?
180
+ AND sessionID NOT LIKE '%$'
181
+ )`,
182
+ [coValueRow.rowID],
183
+ );
184
+
185
+ await this.db.run(
186
+ `DELETE FROM signatureAfter
187
+ WHERE ses IN (
188
+ SELECT rowID FROM sessions
189
+ WHERE coValue = ?
190
+ AND sessionID NOT LIKE '%$'
191
+ )`,
192
+ [coValueRow.rowID],
193
+ );
194
+
195
+ await this.db.run(
196
+ `DELETE FROM sessions
197
+ WHERE coValue = ?
198
+ AND sessionID NOT LIKE '%$'`,
199
+ [coValueRow.rowID],
200
+ );
201
+
202
+ await this.db.run(
203
+ `INSERT INTO deletedCoValues (coValueID, status) VALUES (?, ?)
204
+ ON CONFLICT(coValueID) DO UPDATE SET status=?`,
205
+ [
206
+ coValueId,
207
+ DeletedCoValueDeletionStatus.Done,
208
+ DeletedCoValueDeletionStatus.Done,
209
+ ],
210
+ );
211
+ });
212
+ }
213
+
214
+ async getAllCoValuesWaitingForDelete(): Promise<RawCoID[]> {
215
+ const rows = await this.db.query<DeletedCoValueQueueRow>(
216
+ `SELECT coValueID as id
217
+ FROM deletedCoValues
218
+ WHERE status = ?`,
219
+ [DeletedCoValueDeletionStatus.Pending],
220
+ );
221
+ return rows.map((r) => r.id);
222
+ }
223
+
149
224
  async addSessionUpdate({
150
225
  sessionUpdate,
151
226
  }: {
@@ -17,6 +17,7 @@ import {
17
17
  setSessionCounter,
18
18
  } from "../knownState.js";
19
19
  import { StorageKnownState } from "./knownState.js";
20
+ import { DeletedCoValuesEraserScheduler } from "./DeletedCoValuesEraserScheduler.js";
20
21
  import {
21
22
  collectNewTxs,
22
23
  getDependedOnCoValues,
@@ -30,10 +31,15 @@ import type {
30
31
  StoredCoValueRow,
31
32
  StoredSessionRow,
32
33
  } from "./types.js";
34
+ import { isDeleteSessionID } from "../ids.js";
33
35
 
34
36
  export class StorageApiAsync implements StorageAPI {
35
37
  private readonly dbClient: DBClientInterfaceAsync;
36
38
 
39
+ private deletedCoValuesEraserScheduler:
40
+ | DeletedCoValuesEraserScheduler
41
+ | undefined;
42
+ private eraserController: AbortController | undefined;
37
43
  /**
38
44
  * Keeps track of CoValues that are in memory, to avoid reloading them from storage
39
45
  * when it isn't necessary
@@ -114,6 +120,7 @@ export class StorageApiAsync implements StorageAPI {
114
120
  callback: (data: NewContentMessage) => void,
115
121
  done: (found: boolean) => void,
116
122
  ) {
123
+ this.interruptEraser("load");
117
124
  const coValueRow = await this.dbClient.getCoValue(id);
118
125
 
119
126
  if (!coValueRow) {
@@ -267,10 +274,35 @@ export class StorageApiAsync implements StorageAPI {
267
274
  this.storeQueue.push(msg, correctionCallback);
268
275
 
269
276
  this.storeQueue.processQueue(async (data, correctionCallback) => {
277
+ this.interruptEraser("store");
270
278
  return this.storeSingle(data, correctionCallback);
271
279
  });
272
280
  }
273
281
 
282
+ private interruptEraser(reason: string) {
283
+ // Cooperative cancellation: a DB transaction already in progress will complete,
284
+ // but the eraser loop will stop starting further work at its next abort check.
285
+ if (this.eraserController) {
286
+ this.eraserController.abort(reason);
287
+ this.eraserController = undefined;
288
+ }
289
+ }
290
+
291
+ async eraseAllDeletedCoValues() {
292
+ const ids = await this.dbClient.getAllCoValuesWaitingForDelete();
293
+
294
+ this.eraserController = new AbortController();
295
+ const signal = this.eraserController.signal;
296
+
297
+ for (const id of ids) {
298
+ if (signal.aborted) {
299
+ return;
300
+ }
301
+
302
+ await this.dbClient.eraseCoValueButKeepTombstone(id);
303
+ }
304
+ }
305
+
274
306
  /**
275
307
  * This function is called when the storage lacks the information required to store the incoming content.
276
308
  *
@@ -313,6 +345,7 @@ export class StorageApiAsync implements StorageAPI {
313
345
  msg: NewContentMessage,
314
346
  correctionCallback: CorrectionCallback,
315
347
  ): Promise<boolean> {
348
+ this.interruptEraser("store");
316
349
  if (this.storeQueue.closed) {
317
350
  return false;
318
351
  }
@@ -342,6 +375,10 @@ export class StorageApiAsync implements StorageAPI {
342
375
  sessionID,
343
376
  );
344
377
 
378
+ if (this.deletedValues.has(id) && isDeleteSessionID(sessionID)) {
379
+ await tx.markCoValueAsDeleted(id);
380
+ }
381
+
345
382
  if (sessionRow) {
346
383
  setSessionCounter(
347
384
  knownState.sessions,
@@ -445,6 +482,30 @@ export class StorageApiAsync implements StorageAPI {
445
482
  return newLastIdx;
446
483
  }
447
484
 
485
+ deletedValues = new Set<RawCoID>();
486
+
487
+ markDeleteAsValid(id: RawCoID) {
488
+ this.deletedValues.add(id);
489
+
490
+ if (this.deletedCoValuesEraserScheduler) {
491
+ this.deletedCoValuesEraserScheduler.onEnqueueDeletedCoValue();
492
+ }
493
+ }
494
+
495
+ enableDeletedCoValuesErasure() {
496
+ if (this.deletedCoValuesEraserScheduler) return;
497
+
498
+ this.deletedCoValuesEraserScheduler = new DeletedCoValuesEraserScheduler({
499
+ run: async () => {
500
+ // Async storage: no max-time budgeting; drain to completion when scheduled.
501
+ await this.eraseAllDeletedCoValues();
502
+ const remaining = await this.dbClient.getAllCoValuesWaitingForDelete();
503
+ return { hasMore: remaining.length > 0 };
504
+ },
505
+ });
506
+ this.deletedCoValuesEraserScheduler.scheduleStartupDrain();
507
+ }
508
+
448
509
  waitForSync(id: string, coValue: CoValueCore) {
449
510
  return this.knownStates.waitForSync(id, coValue);
450
511
  }
@@ -471,6 +532,7 @@ export class StorageApiAsync implements StorageAPI {
471
532
  }
472
533
 
473
534
  close() {
535
+ this.deletedCoValuesEraserScheduler?.dispose();
474
536
  this.inMemoryCoValues.clear();
475
537
  return this.storeQueue.close();
476
538
  }
@@ -16,6 +16,7 @@ import {
16
16
  emptyKnownState,
17
17
  setSessionCounter,
18
18
  } from "../knownState.js";
19
+ import { isDeleteSessionID } from "../ids.js";
19
20
  import {
20
21
  collectNewTxs,
21
22
  getDependedOnCoValues,
@@ -29,14 +30,21 @@ import type {
29
30
  StoredCoValueRow,
30
31
  StoredSessionRow,
31
32
  } from "./types.js";
33
+ import { DeletedCoValuesEraserScheduler } from "./DeletedCoValuesEraserScheduler.js";
32
34
  import {
33
35
  ContentCallback,
34
36
  StorageStreamingQueue,
35
37
  } from "../queue/StorageStreamingQueue.js";
36
38
  import { getPriorityFromHeader } from "../priority.js";
37
39
 
40
+ const MAX_DELETE_SCHEDULE_DURATION_MS = 100;
41
+
38
42
  export class StorageApiSync implements StorageAPI {
39
43
  private readonly dbClient: DBClientInterfaceSync;
44
+
45
+ private deletedCoValuesEraserScheduler:
46
+ | DeletedCoValuesEraserScheduler
47
+ | undefined;
40
48
  /**
41
49
  * Keeps track of CoValues that are in memory, to avoid reloading them from storage
42
50
  * when it isn't necessary
@@ -329,6 +337,10 @@ export class StorageApiSync implements StorageAPI {
329
337
 
330
338
  for (const sessionID of Object.keys(msg.new) as SessionID[]) {
331
339
  this.dbClient.transaction((tx) => {
340
+ if (this.deletedValues.has(id) && isDeleteSessionID(sessionID)) {
341
+ tx.markCoValueAsDeleted(id);
342
+ }
343
+
332
344
  const sessionRow = tx.getSingleCoValueSession(
333
345
  storedCoValueRowID,
334
346
  sessionID,
@@ -433,6 +445,51 @@ export class StorageApiSync implements StorageAPI {
433
445
  return newLastIdx;
434
446
  }
435
447
 
448
+ deletedValues = new Set<RawCoID>();
449
+
450
+ markDeleteAsValid(id: RawCoID) {
451
+ this.deletedValues.add(id);
452
+
453
+ if (this.deletedCoValuesEraserScheduler) {
454
+ this.deletedCoValuesEraserScheduler.onEnqueueDeletedCoValue();
455
+ }
456
+ }
457
+
458
+ async eraseAllDeletedCoValues(): Promise<void> {
459
+ const ids = this.dbClient.getAllCoValuesWaitingForDelete();
460
+
461
+ for (const id of ids) {
462
+ this.dbClient.eraseCoValueButKeepTombstone(id);
463
+ }
464
+ }
465
+
466
+ enableDeletedCoValuesErasure() {
467
+ if (this.deletedCoValuesEraserScheduler) return;
468
+ this.deletedCoValuesEraserScheduler = new DeletedCoValuesEraserScheduler({
469
+ run: async () =>
470
+ this.eraseDeletedCoValuesOnceBudgeted(MAX_DELETE_SCHEDULE_DURATION_MS),
471
+ });
472
+ this.deletedCoValuesEraserScheduler.scheduleStartupDrain();
473
+ }
474
+
475
+ private eraseDeletedCoValuesOnceBudgeted(budgetMs?: number) {
476
+ const startedAt = Date.now();
477
+ const ids = this.dbClient.getAllCoValuesWaitingForDelete();
478
+
479
+ for (const id of ids) {
480
+ // Strict time budget for sync storage to avoid blocking.
481
+ if (budgetMs && Date.now() - startedAt >= budgetMs) {
482
+ break;
483
+ }
484
+
485
+ this.dbClient.eraseCoValueButKeepTombstone(id);
486
+ }
487
+
488
+ return {
489
+ hasMore: this.dbClient.getAllCoValuesWaitingForDelete().length > 0,
490
+ };
491
+ }
492
+
436
493
  waitForSync(id: string, coValue: CoValueCore) {
437
494
  return this.knownStates.waitForSync(id, coValue);
438
495
  }
@@ -461,6 +518,7 @@ export class StorageApiSync implements StorageAPI {
461
518
  }
462
519
 
463
520
  close() {
521
+ this.deletedCoValuesEraserScheduler?.dispose();
464
522
  this.inMemoryCoValues.clear();
465
523
  return undefined;
466
524
  }
@@ -13,12 +13,43 @@ export type CorrectionCallback = (
13
13
  correction: CoValueKnownState,
14
14
  ) => NewContentMessage[] | undefined;
15
15
 
16
+ /**
17
+ * Deletion work queue status for `deletedCoValues` (SQLite).
18
+ *
19
+ * Stored as an INTEGER in SQLite:
20
+ * - 0 = pending
21
+ * - 1 = done
22
+ */
23
+ export enum DeletedCoValueDeletionStatus {
24
+ Pending = 0,
25
+ Done = 1,
26
+ }
27
+
16
28
  /**
17
29
  * The StorageAPI is the interface that the StorageSync and StorageAsync classes implement.
18
30
  *
19
31
  * It uses callbacks instead of promises to have no overhead when using the StorageSync and less overhead when using the StorageAsync.
20
32
  */
21
33
  export interface StorageAPI {
34
+ /**
35
+ * Flags that the coValue delete is valid.
36
+ *
37
+ * When the delete tx is stored, the storage will mark the coValue as deleted.
38
+ */
39
+ markDeleteAsValid(id: RawCoID): void;
40
+
41
+ /**
42
+ * Enable the background erasure scheduler that drains the `deletedCoValues` work queue.
43
+ * This is intentionally opt-in and should be activated by `LocalNode`.
44
+ */
45
+ enableDeletedCoValuesErasure(): void;
46
+
47
+ /**
48
+ * Batch physical deletion for coValues queued in `deletedCoValues` with status `Pending`.
49
+ * Must preserve tombstones (header + delete session(s) + their tx/signatures).
50
+ */
51
+ eraseAllDeletedCoValues(): Promise<void>;
52
+
22
53
  load(
23
54
  id: string,
24
55
  // This callback is fired when data is found, might be called multiple times if the content requires streaming (e.g when loading files)
@@ -111,6 +142,13 @@ export interface DBTransactionInterfaceAsync {
111
142
  sessionID: SessionID,
112
143
  ): Promise<StoredSessionRow | undefined>;
113
144
 
145
+ /**
146
+ * Persist a "deleted coValue" marker in storage (work queue entry).
147
+ * This is an enqueue signal: implementations should set status to `Pending`.
148
+ * This is expected to be idempotent (safe to call repeatedly).
149
+ */
150
+ markCoValueAsDeleted(id: RawCoID): Promise<unknown>;
151
+
114
152
  addSessionUpdate({
115
153
  sessionUpdate,
116
154
  sessionRow,
@@ -146,6 +184,11 @@ export interface DBClientInterfaceAsync {
146
184
  header?: CoValueHeader,
147
185
  ): Promise<number | undefined>;
148
186
 
187
+ /**
188
+ * Enumerate all coValue IDs currently pending in the "deleted coValues" work queue.
189
+ */
190
+ getAllCoValuesWaitingForDelete(): Promise<RawCoID[]>;
191
+
149
192
  getCoValueSessions(coValueRowId: number): Promise<StoredSessionRow[]>;
150
193
 
151
194
  getNewTransactionInSession(
@@ -171,6 +214,13 @@ export interface DBClientInterfaceAsync {
171
214
 
172
215
  stopTrackingSyncState(id: RawCoID): Promise<void>;
173
216
 
217
+ /**
218
+ * Physical deletion primitive: erase all persisted history for a deleted coValue,
219
+ * while preserving the tombstone (header + delete session(s)).
220
+ * Must run inside a single storage transaction.
221
+ */
222
+ eraseCoValueButKeepTombstone(coValueID: RawCoID): Promise<unknown>;
223
+
174
224
  /**
175
225
  * Get the knownState for a CoValue without loading transactions.
176
226
  * Returns undefined if the CoValue doesn't exist.
@@ -186,6 +236,13 @@ export interface DBTransactionInterfaceSync {
186
236
  sessionID: SessionID,
187
237
  ): StoredSessionRow | undefined;
188
238
 
239
+ /**
240
+ * Persist a "deleted coValue" marker in storage (work queue entry).
241
+ * This is an enqueue signal: implementations should set status to `"pending"`.
242
+ * This is expected to be idempotent (safe to call repeatedly).
243
+ */
244
+ markCoValueAsDeleted(id: RawCoID): unknown;
245
+
189
246
  addSessionUpdate({
190
247
  sessionUpdate,
191
248
  sessionRow,
@@ -216,6 +273,11 @@ export interface DBClientInterfaceSync {
216
273
 
217
274
  upsertCoValue(id: string, header?: CoValueHeader): number | undefined;
218
275
 
276
+ /**
277
+ * Enumerate all coValue IDs currently pending in the "deleted coValues" work queue.
278
+ */
279
+ getAllCoValuesWaitingForDelete(): RawCoID[];
280
+
219
281
  getCoValueSessions(coValueRowId: number): StoredSessionRow[];
220
282
 
221
283
  getNewTransactionInSession(
@@ -239,6 +301,13 @@ export interface DBClientInterfaceSync {
239
301
 
240
302
  stopTrackingSyncState(id: RawCoID): void;
241
303
 
304
+ /**
305
+ * Physical deletion primitive: erase all persisted history for a deleted coValue,
306
+ * while preserving the tombstone (header + delete session(s)).
307
+ * Must run inside a single storage transaction.
308
+ */
309
+ eraseCoValueButKeepTombstone(coValueID: RawCoID): unknown;
310
+
242
311
  /**
243
312
  * Get the knownState for a CoValue without loading transactions.
244
313
  * Returns undefined if the CoValue doesn't exist.
package/src/sync.ts CHANGED
@@ -14,7 +14,7 @@ import {
14
14
  import { CoValueCore } from "./coValueCore/coValueCore.js";
15
15
  import { CoValueHeader, Transaction } from "./coValueCore/verifiedState.js";
16
16
  import { Signature } from "./crypto/crypto.js";
17
- import { RawCoID, SessionID, isRawCoID } from "./ids.js";
17
+ import { isDeleteSessionID, RawCoID, SessionID, isRawCoID } from "./ids.js";
18
18
  import { LocalNode } from "./localNode.js";
19
19
  import { logger } from "./logger.js";
20
20
  import { CoValuePriority } from "./priority.js";
@@ -213,7 +213,6 @@ export class SyncManager {
213
213
  return;
214
214
  }
215
215
 
216
- // TODO: validate
217
216
  switch (msg.action) {
218
217
  case "load":
219
218
  return this.handleLoad(msg, peer);
@@ -265,10 +264,18 @@ export class SyncManager {
265
264
 
266
265
  peer.combineOptimisticWith(id, coValue.knownState());
267
266
  } else if (!peer.toldKnownState.has(id)) {
268
- this.trySendToPeer(peer, {
269
- action: "known",
270
- ...coValue.knownStateWithStreaming(),
271
- });
267
+ if (coValue.isDeleted) {
268
+ // This way we make the peer believe that we've always ingested all the content they sent, even though we skipped it because the coValue is deleted
269
+ this.trySendToPeer(
270
+ peer,
271
+ coValue.stopSyncingKnownStateMessage(peer.getKnownState(id)),
272
+ );
273
+ } else {
274
+ this.trySendToPeer(peer, {
275
+ action: "known",
276
+ ...coValue.knownStateWithStreaming(),
277
+ });
278
+ }
272
279
  }
273
280
 
274
281
  peer.trackToldKnownState(id);
@@ -875,6 +882,8 @@ export class SyncManager {
875
882
  new: {},
876
883
  };
877
884
 
885
+ let wasAlreadyDeleted = coValue.isDeleted;
886
+
878
887
  /**
879
888
  * The coValue is in memory, load the transactions from the content message
880
889
  */
@@ -882,6 +891,10 @@ export class SyncManager {
882
891
  sessionID,
883
892
  newContentForSession,
884
893
  ] of getSessionEntriesFromContentMessage(msg)) {
894
+ if (wasAlreadyDeleted && !isDeleteSessionID(sessionID)) {
895
+ continue;
896
+ }
897
+
885
898
  const newTransactions = getNewTransactionsFromContentMessage(
886
899
  newContentForSession,
887
900
  coValue.knownState(),
@@ -936,12 +949,25 @@ export class SyncManager {
936
949
  this.recordTransactionsSize(newTransactions, sourceRole);
937
950
  }
938
951
 
952
+ // We reset the new content for the deleted coValue
953
+ // because we want to store only the delete session/transaction
954
+ if (!wasAlreadyDeleted && coValue.isDeleted) {
955
+ wasAlreadyDeleted = true;
956
+ validNewContent.new = {};
957
+ }
958
+
939
959
  // The new content for this session has been verified, so we can store it
940
960
  validNewContent.new[sessionID] = newContentForSession;
941
961
  }
942
962
 
943
963
  if (peer) {
944
- peer.combineWith(msg.id, knownStateFromContent(validNewContent));
964
+ if (coValue.isDeleted) {
965
+ // In case of deleted coValues, we combine the known state with the content message
966
+ // to avoid that clients that don't support deleted coValues try to sync their own content indefinitely
967
+ peer.combineWith(msg.id, knownStateFromContent(msg));
968
+ } else {
969
+ peer.combineWith(msg.id, knownStateFromContent(validNewContent));
970
+ }
945
971
  }
946
972
 
947
973
  /**
@@ -973,10 +999,18 @@ export class SyncManager {
973
999
  * This way the sender knows that the content has been received and applied
974
1000
  * and can update their peer's knownState accordingly.
975
1001
  */
976
- this.trySendToPeer(peer, {
977
- action: "known",
978
- ...coValue.knownState(),
979
- });
1002
+ if (coValue.isDeleted) {
1003
+ // This way we make the peer believe that we've ingested all the content, even though we skipped it because the coValue is deleted
1004
+ this.trySendToPeer(
1005
+ peer,
1006
+ coValue.stopSyncingKnownStateMessage(peer.getKnownState(msg.id)),
1007
+ );
1008
+ } else {
1009
+ this.trySendToPeer(peer, {
1010
+ action: "known",
1011
+ ...coValue.knownState(),
1012
+ });
1013
+ }
980
1014
  peer.trackToldKnownState(msg.id);
981
1015
  }
982
1016
 
@@ -1124,6 +1158,12 @@ export class SyncManager {
1124
1158
 
1125
1159
  const value = this.local.getCoValue(content.id);
1126
1160
 
1161
+ if (value.isDeleted) {
1162
+ // This doesn't persist the delete flag, it only signals the storage
1163
+ // API that the delete transaction is valid
1164
+ storage.markDeleteAsValid(value.id);
1165
+ }
1166
+
1127
1167
  // Try to store the content as-is for performance
1128
1168
  // In case that some transactions are missing, a correction will be requested, but it's an edge case
1129
1169
  storage.store(content, (correction) => {