mindoodb 0.0.23 → 0.0.32

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 (340) hide show
  1. package/README-server.md +9 -1
  2. package/README.md +5 -3
  3. package/dist/appendonlystores/network/ClientNetworkContentAddressedStore.d.ts +1 -1
  4. package/dist/appendonlystores/network/ClientNetworkContentAddressedStore.d.ts.map +1 -1
  5. package/dist/appendonlystores/network/ClientNetworkContentAddressedStore.js +24 -5
  6. package/dist/appendonlystores/network/ClientNetworkContentAddressedStore.js.map +1 -1
  7. package/dist/appendonlystores/network/HttpTransport.d.ts +14 -2
  8. package/dist/appendonlystores/network/HttpTransport.d.ts.map +1 -1
  9. package/dist/appendonlystores/network/HttpTransport.js +124 -27
  10. package/dist/appendonlystores/network/HttpTransport.js.map +1 -1
  11. package/dist/appendonlystores/network/ServerNetworkContentAddressedStore.d.ts +159 -4
  12. package/dist/appendonlystores/network/ServerNetworkContentAddressedStore.d.ts.map +1 -1
  13. package/dist/appendonlystores/network/ServerNetworkContentAddressedStore.js +334 -25
  14. package/dist/appendonlystores/network/ServerNetworkContentAddressedStore.js.map +1 -1
  15. package/dist/browser/appendonlystores/IndexedDBContentAddressedStore.d.ts +9 -0
  16. package/dist/browser/appendonlystores/IndexedDBContentAddressedStore.d.ts.map +1 -1
  17. package/dist/browser/appendonlystores/IndexedDBContentAddressedStore.js +77 -5
  18. package/dist/browser/appendonlystores/IndexedDBContentAddressedStore.js.map +1 -1
  19. package/dist/browser/index.mjs +103 -0
  20. package/dist/core/BaseMindooDB.d.ts +697 -4
  21. package/dist/core/BaseMindooDB.d.ts.map +1 -1
  22. package/dist/core/BaseMindooDB.js +3273 -180
  23. package/dist/core/BaseMindooDB.js.map +1 -1
  24. package/dist/core/BaseMindooTenant.d.ts +192 -1
  25. package/dist/core/BaseMindooTenant.d.ts.map +1 -1
  26. package/dist/core/BaseMindooTenant.js +633 -88
  27. package/dist/core/BaseMindooTenant.js.map +1 -1
  28. package/dist/core/BaseMindooTenantDirectory.d.ts +770 -10
  29. package/dist/core/BaseMindooTenantDirectory.d.ts.map +1 -1
  30. package/dist/core/BaseMindooTenantDirectory.js +2715 -292
  31. package/dist/core/BaseMindooTenantDirectory.js.map +1 -1
  32. package/dist/core/BaseMindooTenantFactory.d.ts +2 -0
  33. package/dist/core/BaseMindooTenantFactory.d.ts.map +1 -1
  34. package/dist/core/BaseMindooTenantFactory.js +20 -0
  35. package/dist/core/BaseMindooTenantFactory.js.map +1 -1
  36. package/dist/core/accesscontrol/AccessDeniedError.d.ts +22 -0
  37. package/dist/core/accesscontrol/AccessDeniedError.d.ts.map +1 -0
  38. package/dist/core/accesscontrol/AccessDeniedError.js +28 -0
  39. package/dist/core/accesscontrol/AccessDeniedError.js.map +1 -0
  40. package/dist/core/accesscontrol/DirectoryStateNode.d.ts +220 -0
  41. package/dist/core/accesscontrol/DirectoryStateNode.d.ts.map +1 -0
  42. package/dist/core/accesscontrol/DirectoryStateNode.js +297 -0
  43. package/dist/core/accesscontrol/DirectoryStateNode.js.map +1 -0
  44. package/dist/core/accesscontrol/DirectoryTimeTravelIndex.d.ts +118 -0
  45. package/dist/core/accesscontrol/DirectoryTimeTravelIndex.d.ts.map +1 -0
  46. package/dist/core/accesscontrol/DirectoryTimeTravelIndex.js +173 -0
  47. package/dist/core/accesscontrol/DirectoryTimeTravelIndex.js.map +1 -0
  48. package/dist/core/accesscontrol/directoryProjection.d.ts +26 -0
  49. package/dist/core/accesscontrol/directoryProjection.d.ts.map +1 -0
  50. package/dist/core/accesscontrol/directoryProjection.js +174 -0
  51. package/dist/core/accesscontrol/directoryProjection.js.map +1 -0
  52. package/dist/core/accesscontrol/evaluate.d.ts +77 -0
  53. package/dist/core/accesscontrol/evaluate.d.ts.map +1 -0
  54. package/dist/core/accesscontrol/evaluate.js +206 -0
  55. package/dist/core/accesscontrol/evaluate.js.map +1 -0
  56. package/dist/core/accesscontrol/grantKeys.d.ts +113 -0
  57. package/dist/core/accesscontrol/grantKeys.d.ts.map +1 -0
  58. package/dist/core/accesscontrol/grantKeys.js +302 -0
  59. package/dist/core/accesscontrol/grantKeys.js.map +1 -0
  60. package/dist/core/accesscontrol/keyBagReconciler.d.ts +22 -0
  61. package/dist/core/accesscontrol/keyBagReconciler.d.ts.map +1 -0
  62. package/dist/core/accesscontrol/keyBagReconciler.js +3 -0
  63. package/dist/core/accesscontrol/keyBagReconciler.js.map +1 -0
  64. package/dist/core/accesscontrol/materializationGuard.d.ts +89 -0
  65. package/dist/core/accesscontrol/materializationGuard.d.ts.map +1 -0
  66. package/dist/core/accesscontrol/materializationGuard.js +69 -0
  67. package/dist/core/accesscontrol/materializationGuard.js.map +1 -0
  68. package/dist/core/accesscontrol/quarantine.d.ts +65 -0
  69. package/dist/core/accesscontrol/quarantine.d.ts.map +1 -0
  70. package/dist/core/accesscontrol/quarantine.js +79 -0
  71. package/dist/core/accesscontrol/quarantine.js.map +1 -0
  72. package/dist/core/accesscontrol/receiptValidation.d.ts +113 -0
  73. package/dist/core/accesscontrol/receiptValidation.d.ts.map +1 -0
  74. package/dist/core/accesscontrol/receiptValidation.js +142 -0
  75. package/dist/core/accesscontrol/receiptValidation.js.map +1 -0
  76. package/dist/core/accesscontrol/timestamp/Ed25519WitnessProvider.d.ts +31 -0
  77. package/dist/core/accesscontrol/timestamp/Ed25519WitnessProvider.d.ts.map +1 -0
  78. package/dist/core/accesscontrol/timestamp/Ed25519WitnessProvider.js +66 -0
  79. package/dist/core/accesscontrol/timestamp/Ed25519WitnessProvider.js.map +1 -0
  80. package/dist/core/accesscontrol/timestamp/TimestampProvider.d.ts +79 -0
  81. package/dist/core/accesscontrol/timestamp/TimestampProvider.d.ts.map +1 -0
  82. package/dist/core/accesscontrol/timestamp/TimestampProvider.js +22 -0
  83. package/dist/core/accesscontrol/timestamp/TimestampProvider.js.map +1 -0
  84. package/dist/core/accesscontrol/types.d.ts +825 -0
  85. package/dist/core/accesscontrol/types.d.ts.map +1 -0
  86. package/dist/core/accesscontrol/types.js +534 -0
  87. package/dist/core/accesscontrol/types.js.map +1 -0
  88. package/dist/core/appendonlystores/InMemoryContentAddressedStore.d.ts +9 -0
  89. package/dist/core/appendonlystores/InMemoryContentAddressedStore.d.ts.map +1 -1
  90. package/dist/core/appendonlystores/InMemoryContentAddressedStore.js +54 -0
  91. package/dist/core/appendonlystores/InMemoryContentAddressedStore.js.map +1 -1
  92. package/dist/core/appendonlystores/network/AuthenticationService.d.ts +38 -2
  93. package/dist/core/appendonlystores/network/AuthenticationService.d.ts.map +1 -1
  94. package/dist/core/appendonlystores/network/AuthenticationService.js +199 -26
  95. package/dist/core/appendonlystores/network/AuthenticationService.js.map +1 -1
  96. package/dist/core/appendonlystores/network/NetworkTransport.d.ts +17 -3
  97. package/dist/core/appendonlystores/network/NetworkTransport.d.ts.map +1 -1
  98. package/dist/core/appendonlystores/network/types.d.ts +58 -4
  99. package/dist/core/appendonlystores/network/types.d.ts.map +1 -1
  100. package/dist/core/appendonlystores/network/types.js +4 -0
  101. package/dist/core/appendonlystores/network/types.js.map +1 -1
  102. package/dist/core/appendonlystores/scanUtils.d.ts +27 -0
  103. package/dist/core/appendonlystores/scanUtils.d.ts.map +1 -0
  104. package/dist/core/appendonlystores/scanUtils.js +87 -0
  105. package/dist/core/appendonlystores/scanUtils.js.map +1 -0
  106. package/dist/core/appendonlystores/types.d.ts +23 -2
  107. package/dist/core/appendonlystores/types.d.ts.map +1 -1
  108. package/dist/core/cache/CacheManager.d.ts +13 -3
  109. package/dist/core/cache/CacheManager.d.ts.map +1 -1
  110. package/dist/core/cache/CacheManager.js +7 -4
  111. package/dist/core/cache/CacheManager.js.map +1 -1
  112. package/dist/core/crypto/EntrySignature.d.ts +137 -0
  113. package/dist/core/crypto/EntrySignature.d.ts.map +1 -0
  114. package/dist/core/crypto/EntrySignature.js +210 -0
  115. package/dist/core/crypto/EntrySignature.js.map +1 -0
  116. package/dist/core/crypto/MindooDocSigner.d.ts.map +1 -1
  117. package/dist/core/crypto/MindooDocSigner.js +6 -2
  118. package/dist/core/crypto/MindooDocSigner.js.map +1 -1
  119. package/dist/core/crypto/WitnessReceipt.d.ts +137 -0
  120. package/dist/core/crypto/WitnessReceipt.d.ts.map +1 -0
  121. package/dist/core/crypto/WitnessReceipt.js +182 -0
  122. package/dist/core/crypto/WitnessReceipt.js.map +1 -0
  123. package/dist/core/crypto/encryptedFields.d.ts +27 -0
  124. package/dist/core/crypto/encryptedFields.d.ts.map +1 -0
  125. package/dist/core/crypto/encryptedFields.js +58 -0
  126. package/dist/core/crypto/encryptedFields.js.map +1 -0
  127. package/dist/core/crypto/pbkdf2Iterations.d.ts +12 -0
  128. package/dist/core/crypto/pbkdf2Iterations.d.ts.map +1 -1
  129. package/dist/core/crypto/pbkdf2Iterations.js +23 -0
  130. package/dist/core/crypto/pbkdf2Iterations.js.map +1 -1
  131. package/dist/core/crypto/privateKeyEncryption.d.ts.map +1 -1
  132. package/dist/core/crypto/privateKeyEncryption.js +3 -1
  133. package/dist/core/crypto/privateKeyEncryption.js.map +1 -1
  134. package/dist/core/databaseIdValidation.d.ts +1 -1
  135. package/dist/core/databaseIdValidation.d.ts.map +1 -1
  136. package/dist/core/databaseIdValidation.js +4 -4
  137. package/dist/core/databaseIdValidation.js.map +1 -1
  138. package/dist/core/errors.d.ts +26 -0
  139. package/dist/core/errors.d.ts.map +1 -1
  140. package/dist/core/errors.js +42 -1
  141. package/dist/core/errors.js.map +1 -1
  142. package/dist/core/expressions/builder.d.ts +102 -0
  143. package/dist/core/expressions/builder.d.ts.map +1 -0
  144. package/dist/core/expressions/builder.js +249 -0
  145. package/dist/core/expressions/builder.js.map +1 -0
  146. package/dist/core/expressions/evaluateExpression.d.ts +78 -0
  147. package/dist/core/expressions/evaluateExpression.d.ts.map +1 -0
  148. package/dist/core/expressions/evaluateExpression.js +386 -0
  149. package/dist/core/expressions/evaluateExpression.js.map +1 -0
  150. package/dist/core/expressions/formulaSource.d.ts +24 -0
  151. package/dist/core/expressions/formulaSource.d.ts.map +1 -0
  152. package/dist/core/expressions/formulaSource.js +765 -0
  153. package/dist/core/expressions/formulaSource.js.map +1 -0
  154. package/dist/core/expressions/index.d.ts +8 -0
  155. package/dist/core/expressions/index.d.ts.map +1 -0
  156. package/dist/core/expressions/index.js +46 -0
  157. package/dist/core/expressions/index.js.map +1 -0
  158. package/dist/core/expressions/metadata.d.ts +23 -0
  159. package/dist/core/expressions/metadata.d.ts.map +1 -0
  160. package/dist/core/expressions/metadata.js +191 -0
  161. package/dist/core/expressions/metadata.js.map +1 -0
  162. package/dist/core/expressions/types.d.ts +164 -0
  163. package/dist/core/expressions/types.d.ts.map +1 -0
  164. package/dist/core/expressions/types.js +3 -0
  165. package/dist/core/expressions/types.js.map +1 -0
  166. package/dist/core/extraction/types.d.ts +47 -0
  167. package/dist/core/extraction/types.d.ts.map +1 -0
  168. package/dist/core/extraction/types.js +42 -0
  169. package/dist/core/extraction/types.js.map +1 -0
  170. package/dist/core/index.d.ts +13 -3
  171. package/dist/core/index.d.ts.map +1 -1
  172. package/dist/core/index.js +113 -4
  173. package/dist/core/index.js.map +1 -1
  174. package/dist/core/indexing/createViewDataProvider.d.ts +84 -0
  175. package/dist/core/indexing/createViewDataProvider.d.ts.map +1 -0
  176. package/dist/core/indexing/createViewDataProvider.js +139 -0
  177. package/dist/core/indexing/createViewDataProvider.js.map +1 -0
  178. package/dist/core/indexing/fulltext/DocumentFullTextIndex.d.ts +180 -0
  179. package/dist/core/indexing/fulltext/DocumentFullTextIndex.d.ts.map +1 -0
  180. package/dist/core/indexing/fulltext/DocumentFullTextIndex.js +548 -0
  181. package/dist/core/indexing/fulltext/DocumentFullTextIndex.js.map +1 -0
  182. package/dist/core/indexing/fulltext/SearchEngineAdapter.d.ts +69 -0
  183. package/dist/core/indexing/fulltext/SearchEngineAdapter.d.ts.map +1 -0
  184. package/dist/core/indexing/fulltext/SearchEngineAdapter.js +183 -0
  185. package/dist/core/indexing/fulltext/SearchEngineAdapter.js.map +1 -0
  186. package/dist/core/indexing/fulltext/extractFulltextText.d.ts +34 -0
  187. package/dist/core/indexing/fulltext/extractFulltextText.d.ts.map +1 -0
  188. package/dist/core/indexing/fulltext/extractFulltextText.js +129 -0
  189. package/dist/core/indexing/fulltext/extractFulltextText.js.map +1 -0
  190. package/dist/core/indexing/fulltext/index.d.ts +5 -0
  191. package/dist/core/indexing/fulltext/index.d.ts.map +1 -0
  192. package/dist/core/indexing/fulltext/index.js +20 -0
  193. package/dist/core/indexing/fulltext/index.js.map +1 -0
  194. package/dist/core/indexing/fulltext/types.d.ts +168 -0
  195. package/dist/core/indexing/fulltext/types.d.ts.map +1 -0
  196. package/dist/core/indexing/fulltext/types.js +89 -0
  197. package/dist/core/indexing/fulltext/types.js.map +1 -0
  198. package/dist/core/indexing/summary/DocumentSummaryStore.d.ts +137 -0
  199. package/dist/core/indexing/summary/DocumentSummaryStore.d.ts.map +1 -0
  200. package/dist/core/indexing/summary/DocumentSummaryStore.js +438 -0
  201. package/dist/core/indexing/summary/DocumentSummaryStore.js.map +1 -0
  202. package/dist/core/indexing/summary/SummaryVirtualViewDataProvider.d.ts +72 -0
  203. package/dist/core/indexing/summary/SummaryVirtualViewDataProvider.d.ts.map +1 -0
  204. package/dist/core/indexing/summary/SummaryVirtualViewDataProvider.js +221 -0
  205. package/dist/core/indexing/summary/SummaryVirtualViewDataProvider.js.map +1 -0
  206. package/dist/core/indexing/summary/extractSummaryFields.d.ts +75 -0
  207. package/dist/core/indexing/summary/extractSummaryFields.d.ts.map +1 -0
  208. package/dist/core/indexing/summary/extractSummaryFields.js +266 -0
  209. package/dist/core/indexing/summary/extractSummaryFields.js.map +1 -0
  210. package/dist/core/indexing/summary/index.d.ts +5 -0
  211. package/dist/core/indexing/summary/index.d.ts.map +1 -0
  212. package/dist/core/indexing/summary/index.js +23 -0
  213. package/dist/core/indexing/summary/index.js.map +1 -0
  214. package/dist/core/indexing/summary/types.d.ts +119 -0
  215. package/dist/core/indexing/summary/types.d.ts.map +1 -0
  216. package/dist/core/indexing/summary/types.js +81 -0
  217. package/dist/core/indexing/summary/types.js.map +1 -0
  218. package/dist/core/indexing/virtualviews/IVirtualViewDataProvider.d.ts +47 -1
  219. package/dist/core/indexing/virtualviews/IVirtualViewDataProvider.d.ts.map +1 -1
  220. package/dist/core/indexing/virtualviews/MindooDBVirtualViewDataProvider.d.ts +24 -8
  221. package/dist/core/indexing/virtualviews/MindooDBVirtualViewDataProvider.d.ts.map +1 -1
  222. package/dist/core/indexing/virtualviews/MindooDBVirtualViewDataProvider.js +96 -41
  223. package/dist/core/indexing/virtualviews/MindooDBVirtualViewDataProvider.js.map +1 -1
  224. package/dist/core/indexing/virtualviews/ViewEntrySortKey.d.ts +2 -0
  225. package/dist/core/indexing/virtualviews/ViewEntrySortKey.d.ts.map +1 -1
  226. package/dist/core/indexing/virtualviews/ViewEntrySortKey.js +8 -3
  227. package/dist/core/indexing/virtualviews/ViewEntrySortKey.js.map +1 -1
  228. package/dist/core/indexing/virtualviews/VirtualView.d.ts +111 -5
  229. package/dist/core/indexing/virtualviews/VirtualView.d.ts.map +1 -1
  230. package/dist/core/indexing/virtualviews/VirtualView.js +266 -75
  231. package/dist/core/indexing/virtualviews/VirtualView.js.map +1 -1
  232. package/dist/core/indexing/virtualviews/VirtualViewColumn.d.ts +12 -0
  233. package/dist/core/indexing/virtualviews/VirtualViewColumn.d.ts.map +1 -1
  234. package/dist/core/indexing/virtualviews/VirtualViewColumn.js +1 -0
  235. package/dist/core/indexing/virtualviews/VirtualViewColumn.js.map +1 -1
  236. package/dist/core/indexing/virtualviews/VirtualViewEntryData.d.ts +12 -0
  237. package/dist/core/indexing/virtualviews/VirtualViewEntryData.d.ts.map +1 -1
  238. package/dist/core/indexing/virtualviews/VirtualViewEntryData.js +66 -5
  239. package/dist/core/indexing/virtualviews/VirtualViewEntryData.js.map +1 -1
  240. package/dist/core/indexing/virtualviews/VirtualViewFactory.d.ts +50 -6
  241. package/dist/core/indexing/virtualviews/VirtualViewFactory.d.ts.map +1 -1
  242. package/dist/core/indexing/virtualviews/VirtualViewFactory.js +54 -12
  243. package/dist/core/indexing/virtualviews/VirtualViewFactory.js.map +1 -1
  244. package/dist/core/indexing/virtualviews/index.d.ts +3 -3
  245. package/dist/core/indexing/virtualviews/index.d.ts.map +1 -1
  246. package/dist/core/indexing/virtualviews/index.js.map +1 -1
  247. package/dist/core/indexing/virtualviews/types.d.ts +6 -0
  248. package/dist/core/indexing/virtualviews/types.d.ts.map +1 -1
  249. package/dist/core/indexing/virtualviews/types.js +11 -3
  250. package/dist/core/indexing/virtualviews/types.js.map +1 -1
  251. package/dist/core/keys/KeyBag.d.ts +25 -0
  252. package/dist/core/keys/KeyBag.d.ts.map +1 -1
  253. package/dist/core/keys/KeyBag.js +37 -1
  254. package/dist/core/keys/KeyBag.js.map +1 -1
  255. package/dist/core/query/executeQuery.d.ts +18 -0
  256. package/dist/core/query/executeQuery.d.ts.map +1 -0
  257. package/dist/core/query/executeQuery.js +313 -0
  258. package/dist/core/query/executeQuery.js.map +1 -0
  259. package/dist/core/query/index.d.ts +5 -0
  260. package/dist/core/query/index.d.ts.map +1 -0
  261. package/dist/core/query/index.js +14 -0
  262. package/dist/core/query/index.js.map +1 -0
  263. package/dist/core/query/queryLive.d.ts +27 -0
  264. package/dist/core/query/queryLive.d.ts.map +1 -0
  265. package/dist/core/query/queryLive.js +104 -0
  266. package/dist/core/query/queryLive.js.map +1 -0
  267. package/dist/core/query/queryView.d.ts +156 -0
  268. package/dist/core/query/queryView.d.ts.map +1 -0
  269. package/dist/core/query/queryView.js +266 -0
  270. package/dist/core/query/queryView.js.map +1 -0
  271. package/dist/core/query/types.d.ts +121 -0
  272. package/dist/core/query/types.d.ts.map +1 -0
  273. package/dist/core/query/types.js +18 -0
  274. package/dist/core/query/types.js.map +1 -0
  275. package/dist/core/storeEntryTime.d.ts +57 -0
  276. package/dist/core/storeEntryTime.d.ts.map +1 -0
  277. package/dist/core/storeEntryTime.js +72 -0
  278. package/dist/core/storeEntryTime.js.map +1 -0
  279. package/dist/core/types.d.ts +1509 -39
  280. package/dist/core/types.d.ts.map +1 -1
  281. package/dist/core/types.js +13 -1
  282. package/dist/core/types.js.map +1 -1
  283. package/dist/core/uri/MindooURI.d.ts +53 -1
  284. package/dist/core/uri/MindooURI.d.ts.map +1 -1
  285. package/dist/core/uri/MindooURI.js +135 -0
  286. package/dist/core/uri/MindooURI.js.map +1 -1
  287. package/dist/core/utils/urlSafety.d.ts +46 -0
  288. package/dist/core/utils/urlSafety.d.ts.map +1 -0
  289. package/dist/core/utils/urlSafety.js +144 -0
  290. package/dist/core/utils/urlSafety.js.map +1 -0
  291. package/dist/node/appendonlystores/BasicOnDiskContentAddressedStore.d.ts +14 -0
  292. package/dist/node/appendonlystores/BasicOnDiskContentAddressedStore.d.ts.map +1 -1
  293. package/dist/node/appendonlystores/BasicOnDiskContentAddressedStore.js +95 -4
  294. package/dist/node/appendonlystores/BasicOnDiskContentAddressedStore.js.map +1 -1
  295. package/dist/node/server/CapabilityMatcher.d.ts +10 -0
  296. package/dist/node/server/CapabilityMatcher.d.ts.map +1 -1
  297. package/dist/node/server/CapabilityMatcher.js +14 -2
  298. package/dist/node/server/CapabilityMatcher.js.map +1 -1
  299. package/dist/node/server/MindooDBServer.d.ts.map +1 -1
  300. package/dist/node/server/MindooDBServer.js +117 -8
  301. package/dist/node/server/MindooDBServer.js.map +1 -1
  302. package/dist/node/server/PurgedDocRegistry.d.ts +32 -0
  303. package/dist/node/server/PurgedDocRegistry.d.ts.map +1 -0
  304. package/dist/node/server/PurgedDocRegistry.js +94 -0
  305. package/dist/node/server/PurgedDocRegistry.js.map +1 -0
  306. package/dist/node/server/ServerSync.d.ts.map +1 -1
  307. package/dist/node/server/ServerSync.js +5 -2
  308. package/dist/node/server/ServerSync.js.map +1 -1
  309. package/dist/node/server/SystemAdminAuth.d.ts +10 -0
  310. package/dist/node/server/SystemAdminAuth.d.ts.map +1 -1
  311. package/dist/node/server/SystemAdminAuth.js +25 -0
  312. package/dist/node/server/SystemAdminAuth.js.map +1 -1
  313. package/dist/node/server/TenantManager.d.ts +95 -1
  314. package/dist/node/server/TenantManager.d.ts.map +1 -1
  315. package/dist/node/server/TenantManager.js +382 -15
  316. package/dist/node/server/TenantManager.js.map +1 -1
  317. package/dist/node/server/config.d.ts.map +1 -1
  318. package/dist/node/server/config.js +5 -1
  319. package/dist/node/server/config.js.map +1 -1
  320. package/dist/node/server/types.d.ts +29 -3
  321. package/dist/node/server/types.d.ts.map +1 -1
  322. package/dist/node/server/types.js +12 -0
  323. package/dist/node/server/types.js.map +1 -1
  324. package/dist/node/server/validation.d.ts +6 -0
  325. package/dist/node/server/validation.d.ts.map +1 -1
  326. package/dist/node/server/validation.js +7 -1
  327. package/dist/node/server/validation.js.map +1 -1
  328. package/package.json +4 -3
  329. package/dist/appendonlystores/BasicOnDiskContentAddressedStoreFactory.d.ts +0 -8
  330. package/dist/appendonlystores/BasicOnDiskContentAddressedStoreFactory.d.ts.map +0 -1
  331. package/dist/appendonlystores/BasicOnDiskContentAddressedStoreFactory.js +0 -16
  332. package/dist/appendonlystores/BasicOnDiskContentAddressedStoreFactory.js.map +0 -1
  333. package/dist/core/appendonlystores/BasicOnDiskContentAddressedStore.d.ts +0 -68
  334. package/dist/core/appendonlystores/BasicOnDiskContentAddressedStore.d.ts.map +0 -1
  335. package/dist/core/appendonlystores/BasicOnDiskContentAddressedStore.js +0 -659
  336. package/dist/core/appendonlystores/BasicOnDiskContentAddressedStore.js.map +0 -1
  337. package/dist/node/attachments/writeAttachmentToFile.d.ts +0 -15
  338. package/dist/node/attachments/writeAttachmentToFile.d.ts.map +0 -1
  339. package/dist/node/attachments/writeAttachmentToFile.js +0 -73
  340. package/dist/node/attachments/writeAttachmentToFile.js.map +0 -1
@@ -5,6 +5,7 @@ exports.BaseMindooDB = exports.CUSTOM_DOC_ID_INITIAL_CHANGE_BYTES = void 0;
5
5
  // React Native: native Rust (react-native-automerge-generated)
6
6
  // Browser/Node.js: WASM (@automerge/automerge/slim)
7
7
  const automerge_adapter_1 = require("./automerge-adapter");
8
+ const storeEntryTime_1 = require("./storeEntryTime");
8
9
  const uuid_1 = require("uuid");
9
10
  const types_1 = require("./types");
10
11
  const types_2 = require("./appendonlystores/types");
@@ -15,8 +16,20 @@ const DocumentConflictAnalysis_1 = require("./DocumentConflictAnalysis");
15
16
  const AttachmentReadPlanner_1 = require("./appendonlystores/AttachmentReadPlanner");
16
17
  const idGeneration_1 = require("./utils/idGeneration");
17
18
  const errors_1 = require("./errors");
19
+ const EntrySignature_1 = require("./crypto/EntrySignature");
20
+ const materializationGuard_1 = require("./accesscontrol/materializationGuard");
21
+ const Ed25519WitnessProvider_1 = require("./accesscontrol/timestamp/Ed25519WitnessProvider");
22
+ const AccessDeniedError_1 = require("./accesscontrol/AccessDeniedError");
18
23
  const logging_1 = require("./logging");
19
24
  const databaseIdValidation_1 = require("./databaseIdValidation");
25
+ const DocumentSummaryStore_1 = require("./indexing/summary/DocumentSummaryStore");
26
+ const types_3 = require("./indexing/summary/types");
27
+ const DocumentFullTextIndex_1 = require("./indexing/fulltext/DocumentFullTextIndex");
28
+ const types_4 = require("./indexing/fulltext/types");
29
+ const types_5 = require("./extraction/types");
30
+ const executeQuery_1 = require("./query/executeQuery");
31
+ const queryView_1 = require("./query/queryView");
32
+ const queryLive_1 = require("./query/queryLive");
20
33
  /**
21
34
  * Default chunk size for attachments: 256KB
22
35
  */
@@ -129,18 +142,73 @@ function getCustomIdInitialChangeBytes() {
129
142
  */
130
143
  class BaseMindooDB {
131
144
  constructor(tenant, store, attachmentStore, attachmentConfig, documentCacheConfig, snapshotConfig, adminOnlyDb = false, logger, performanceCallback, timeTravelDate = null) {
145
+ /**
146
+ * Per-tenant quarantine/audit log of entries rejected during materialization
147
+ * (docs/accesscontrol.md §10). In-memory; surfaced via {@link getQuarantineLog}
148
+ * for Haven's audit view.
149
+ */
150
+ this.quarantineLog = [];
151
+ /**
152
+ * Short-TTL cache of "is access control active for this tenant?" so the
153
+ * materialization fast-path does not re-query the directory on every load.
154
+ */
155
+ this.aclActiveCache = null;
156
+ /**
157
+ * Per-document cache of the original creator's signing public key (the
158
+ * `doc_create` entry's `createdByPublicKey`). Used by the client write
159
+ * prechecks to resolve `$author` (`isAuthor`) without re-scanning metadata on
160
+ * every change/delete. Populated on create and lazily on first lookup.
161
+ */
162
+ this.creatorKeyCache = new Map();
132
163
  // Internal changefeed index: sorted by (changeSeq, docId) for deterministic iteration.
133
164
  // lastModified remains available for UX metadata but is not the primary cursor key.
134
165
  this.index = [];
135
166
  // Lookup map for O(1) access to index entries by docId
136
167
  this.indexLookup = new Map(); // Map<docId, arrayIndex>
168
+ /**
169
+ * Lazily-deferred repair marker for {@link indexLookup}: when set, positions
170
+ * stored in the map for array indices >= this value may be stale (entries
171
+ * shifted by an {@link updateIndex} splice). Key presence is always exact.
172
+ * Reads go through {@link getDocIndexPosition}, which validates a hit
173
+ * against {@link index} and triggers {@link flushIndexLookupPatch} only when
174
+ * the position actually moved. This turns the per-update O(index size)
175
+ * lookup rewrite into a single deferred pass — bulk syncs that append at the
176
+ * index tail never pay it at all.
177
+ */
178
+ this.indexLookupStaleFrom = null;
137
179
  this.nextChangeSeq = 1;
138
180
  // Cache of loaded documents: Map<docId, InternalDoc>
139
181
  this.docCache = new Map();
140
182
  this.wrappedInternalDocs = new WeakMap();
183
+ /**
184
+ * Per-document count of local writes since the last snapshot eligibility
185
+ * check that scanned the store. Lets {@link maybeWriteSnapshotForDocument}
186
+ * skip the per-doc metadata scan entirely while a document is still far
187
+ * below {@link snapshotMinChanges}. Absent key = unknown (scan once).
188
+ */
189
+ this.writesSinceSnapshotCheck = new Map();
141
190
  // Track which entry IDs we've already processed
142
191
  this.processedEntryIds = [];
143
192
  this.processedEntryCursor = null;
193
+ /**
194
+ * In-memory LRU of per-document merged state used by the revision-grain
195
+ * changefeed ({@link iterateRevisionsInternal}). Each entry holds a document's
196
+ * fully-merged Automerge doc together with the exact set of replay entry ids
197
+ * that merge covers, so an incremental feed advance can seed the next fold
198
+ * from the cached state (applying only the newly-arrived suffix) instead of
199
+ * re-decrypting and re-folding the document's whole history. Bounded by
200
+ * {@link revisionFeedCacheLimit}; least-recently-used docs are evicted and
201
+ * reconstructed from the best snapshot on the next touch.
202
+ */
203
+ this.revisionFeedDocCache = new Map();
204
+ this.revisionFeedCacheLimit = 256;
205
+ /**
206
+ * Window size for batching encrypted-payload loads while folding a document's
207
+ * revision suffix ({@link foldDocRevisions}): one {@link ContentAddressedStore.getEntries}
208
+ * round trip per window instead of one per revision, while bounding peak
209
+ * memory and response size on a snapshot-less cold-start re-fold.
210
+ */
211
+ this.revisionFoldLoadWindow = 256;
144
212
  // Index: automergeHash -> entryId for each document
145
213
  // Used for resolving Automerge dependency hashes to entry IDs
146
214
  this.automergeHashToEntryId = new Map(); // Map<docId, Map<automergeHash, entryId>>
@@ -150,8 +218,65 @@ class BaseMindooDB {
150
218
  // Local cache support
151
219
  this.cacheManager = null;
152
220
  this.cachePrefix = null;
221
+ /**
222
+ * Lazily created document summary buffer backing ad-hoc queries and
223
+ * summary-based views. `null` until {@link getSummaryStore} is first
224
+ * called, so databases that never query pay no cost.
225
+ */
226
+ this.summaryStore = null;
227
+ /**
228
+ * Lazily created full-text index (opt-in via `fulltextSetup` or an
229
+ * explicit config). `null` until {@link getFullTextIndex} is first
230
+ * called or the setup document enables indexing.
231
+ */
232
+ this.fulltextIndex = null;
233
+ /**
234
+ * Attachment text extractors registered by the host environment (see
235
+ * {@link registerAttachmentTextExtractor}). Consulted lazily by the
236
+ * full-text index while documents with attachments are indexed.
237
+ */
238
+ this.attachmentTextExtractors = [];
239
+ // ---------------------------------------------------------------------------
240
+ // Change notification state (see addChangeListener). `updateIndex` is the
241
+ // common choke point of all mutation paths, so it only records pending
242
+ // docIds; emission is coalesced per sync batch / event-loop turn.
243
+ // ---------------------------------------------------------------------------
244
+ this.changeListeners = new Set();
245
+ this.pendingChangeNotifications = new Map();
246
+ this.changeNotifyTimer = null;
247
+ /** While > 0, timer-based emission is suppressed (e.g. during a sync batch). */
248
+ this.changeNotificationHolds = 0;
249
+ // ---------------------------------------------------------------------------
250
+ // Summary auto-follow (see setSummaryAutoUpdateEnabled): keeps the summary
251
+ // buffer current as changes arrive (local writes and sync batches), so the
252
+ // first query after receiving thousands of documents doesn't stall on a
253
+ // full catch-up run.
254
+ // ---------------------------------------------------------------------------
255
+ this.summaryAutoUpdateEnabled = true;
256
+ this.summaryAutoUpdateRunning = false;
257
+ this.summaryAutoUpdatePending = false;
258
+ /** Whether the setup document was already probed for auto-activation. */
259
+ this.summarySetupProbed = false;
260
+ // ---------------------------------------------------------------------------
261
+ // Full-text auto-follow: mirrors the summary auto-follow, but the index
262
+ // is opt-in — auto-activation only happens when the setup document has
263
+ // `fulltextSetup.enabled: true`.
264
+ // ---------------------------------------------------------------------------
265
+ this.fulltextAutoUpdateEnabled = true;
266
+ this.fulltextAutoUpdateRunning = false;
267
+ this.fulltextAutoUpdatePending = false;
268
+ /** Whether the setup document was already probed for FTS auto-activation. */
269
+ this.fulltextSetupProbed = false;
153
270
  this.dirtyDocIds = new Set();
154
271
  this.cacheMetaDirty = false;
272
+ /**
273
+ * Per-document fingerprint (`changeSeq:automergeHeads`) of the state last
274
+ * written to the L2 cache by {@link flushDirtyDocToCache}. Lets the periodic
275
+ * flush skip the expensive `Automerge.save` + L2 write for documents that
276
+ * were marked dirty but whose persisted state is already current (e.g. docs
277
+ * that were merely loaded and cached, not changed).
278
+ */
279
+ this.lastFlushedDocState = new Map();
155
280
  /**
156
281
  * Tenant KeyBag fingerprint observed at the time the in-memory index
157
282
  * was last reconciled with the underlying store.
@@ -221,6 +346,25 @@ class BaseMindooDB {
221
346
  logger ||
222
347
  new logging_1.MindooLogger((0, logging_1.getDefaultLogLevel)(), "BaseMindooDB", true);
223
348
  this.performanceCallback = performanceCallback;
349
+ // Summary auto-follow: piggybacks on the coalesced change events (one
350
+ // per sync batch / event-loop turn). Costs a docId check per event for
351
+ // databases that never use summaries.
352
+ this.addChangeListener((event) => {
353
+ this.handleChangesForSummaryAutoUpdate(event.changes);
354
+ });
355
+ // Full-text auto-follow: same coalesced events, opt-in activation.
356
+ this.addChangeListener((event) => {
357
+ this.handleChangesForFulltextAutoUpdate(event.changes);
358
+ });
359
+ }
360
+ /**
361
+ * Whether debug logging is enabled. Used to guard hot-path debug statements
362
+ * whose message interpolation is itself expensive (e.g.
363
+ * `JSON.stringify(Automerge.getHeads(...))` — a WASM call plus stringify per
364
+ * document), so bulk operations don't pay for logging that is discarded.
365
+ */
366
+ isDebugEnabled() {
367
+ return this.logger.isLevelEnabled(logging_1.LogLevel.DEBUG);
224
368
  }
225
369
  /**
226
370
  * Get the admin public key from the tenant.
@@ -276,6 +420,503 @@ class BaseMindooDB {
276
420
  const cacheIdentity = this.store.getCacheIdentity?.() ?? this.store.getId();
277
421
  this.cachePrefix = `${this.tenant.getId()}/${cacheIdentity}`;
278
422
  cacheManager.register(this);
423
+ // A summary store created before the cache manager was attached picks
424
+ // up persistence now.
425
+ this.summaryStore?.attachCache(cacheManager, `${this.getCachePrefix()}/summary`);
426
+ this.fulltextIndex?.attachCache(cacheManager, `${this.getCachePrefix()}/fulltext`);
427
+ }
428
+ /**
429
+ * Get (lazily creating) the document summary buffer for this database.
430
+ *
431
+ * The summary is a changefeed-maintained map of queryable field values
432
+ * per document — the substrate for `query()`/`queryView()` (see
433
+ * docs/adhoc-queries.md). Passing a `config` on a later call replaces the
434
+ * configuration; if it differs from the active one, a resumable backfill
435
+ * re-extracts all documents on the next `update()` (documents are never
436
+ * rewritten).
437
+ */
438
+ getSummaryStore(config) {
439
+ if (!this.summaryStore) {
440
+ this.summaryStore = new DocumentSummaryStore_1.DocumentSummaryStore(this, config);
441
+ if (this.cacheManager) {
442
+ this.summaryStore.attachCache(this.cacheManager, `${this.getCachePrefix()}/summary`);
443
+ }
444
+ }
445
+ else if (config !== undefined) {
446
+ this.summaryStore.setConfig(config);
447
+ }
448
+ return this.summaryStore;
449
+ }
450
+ /**
451
+ * Read the summary configuration stored in the synced setup document
452
+ * (`dbsetup`, field `summarySetup`) — the "design document" that lets
453
+ * users/apps (e.g. the Haven UI) configure the summary fields in one
454
+ * place for all replicas. Returns `null` when no setup document or no
455
+ * configuration exists.
456
+ */
457
+ async getSummarySetup() {
458
+ try {
459
+ const doc = await this.getDocument(types_3.DB_SETUP_DOC_ID);
460
+ return (0, types_3.sanitizeSummaryConfig)(doc.getData()[types_3.SUMMARY_SETUP_FIELD]) ?? null;
461
+ }
462
+ catch (error) {
463
+ if (error instanceof errors_1.DocumentNotFoundError || error instanceof errors_1.DocumentDeletedError) {
464
+ return null;
465
+ }
466
+ throw error;
467
+ }
468
+ }
469
+ /**
470
+ * Write the summary configuration into the synced setup document
471
+ * (creating `dbsetup` idempotently when missing; custom-ID documents
472
+ * share seeded Automerge ancestry, so concurrent creates on different
473
+ * replicas merge). Passing `null` removes the configuration (replicas
474
+ * fall back to the default auto-include config).
475
+ *
476
+ * Summary stores without an explicit code-provided configuration pick
477
+ * the change up through the changefeed and re-extract via a resumable
478
+ * backfill — locally and, after sync, on every other replica.
479
+ */
480
+ async setSummarySetup(config) {
481
+ const doc = await this.createDocument({ id: types_3.DB_SETUP_DOC_ID });
482
+ await this.changeDoc(doc, (d) => {
483
+ const data = d.getData();
484
+ if (config === null) {
485
+ delete data[types_3.SUMMARY_SETUP_FIELD];
486
+ }
487
+ else {
488
+ data[types_3.SUMMARY_SETUP_FIELD] = (0, types_3.sanitizeSummaryConfig)(config) ?? {};
489
+ }
490
+ });
491
+ }
492
+ /**
493
+ * Enable/disable summary auto-follow (default: enabled). When enabled,
494
+ * the summary buffer is brought up to date in the background after every
495
+ * coalesced change event — one catch-up run per sync batch, one per
496
+ * event-loop turn for local writes — so queries don't stall on a full
497
+ * catch-up after receiving many documents. Compared to the network I/O
498
+ * of a sync the incremental extraction is cheap; disable it for
499
+ * bulk-import scenarios where even that is unwanted (the next query
500
+ * catches up lazily as before).
501
+ */
502
+ setSummaryAutoUpdateEnabled(enabled) {
503
+ this.summaryAutoUpdateEnabled = enabled;
504
+ if (enabled && this.summaryStore) {
505
+ this.scheduleSummaryAutoUpdate();
506
+ }
507
+ }
508
+ /**
509
+ * Change-event hook for summary auto-follow. While no summary store
510
+ * exists, the setup document decides whether to activate one: it is
511
+ * probed on the first event (covers reopening a database whose `dbsetup`
512
+ * synced earlier) and re-checked whenever a `dbsetup` change arrives
513
+ * (covers the configuration being created/updated locally or via sync).
514
+ */
515
+ handleChangesForSummaryAutoUpdate(changes) {
516
+ if (!this.summaryAutoUpdateEnabled) {
517
+ return;
518
+ }
519
+ if (this.summaryStore) {
520
+ this.scheduleSummaryAutoUpdate();
521
+ return;
522
+ }
523
+ // Never auto-activate on system databases: the directory has its own
524
+ // indexing (DirectoryTimeTravelIndex) and its change events interleave
525
+ // with access-control evaluation; admin-only databases hold no user
526
+ // data worth summarizing. Explicit getSummaryStore() calls still work.
527
+ if (this._isAdminOnlyDb || this.store.getId() === "directory") {
528
+ return;
529
+ }
530
+ const hasSetupChange = changes.some((change) => change.docId === types_3.DB_SETUP_DOC_ID);
531
+ if (!hasSetupChange && this.summarySetupProbed) {
532
+ return;
533
+ }
534
+ this.summarySetupProbed = true;
535
+ void this.activateSummaryFromSetupDoc();
536
+ }
537
+ /** Create the summary store when the setup document configures one. */
538
+ async activateSummaryFromSetupDoc() {
539
+ try {
540
+ const config = await this.getSummarySetup();
541
+ if (config === null || this.summaryStore || !this.summaryAutoUpdateEnabled) {
542
+ return;
543
+ }
544
+ // No explicit config: the store adopts the setup-document config on
545
+ // its first update run (seedConfigFromSetupDoc).
546
+ this.getSummaryStore();
547
+ this.scheduleSummaryAutoUpdate();
548
+ }
549
+ catch (error) {
550
+ this.logger.warn(`Summary auto-activation failed: ${error}`);
551
+ }
552
+ }
553
+ /**
554
+ * Run a background summary catch-up, coalescing further requests into
555
+ * one follow-up run (mirrors the live-view scheduling in VirtualView).
556
+ * Errors are logged and never propagate into write/sync paths.
557
+ */
558
+ scheduleSummaryAutoUpdate() {
559
+ if (this.summaryAutoUpdateRunning) {
560
+ this.summaryAutoUpdatePending = true;
561
+ return;
562
+ }
563
+ this.summaryAutoUpdateRunning = true;
564
+ void (async () => {
565
+ try {
566
+ do {
567
+ this.summaryAutoUpdatePending = false;
568
+ await this.summaryStore?.update();
569
+ } while (this.summaryAutoUpdatePending && this.summaryAutoUpdateEnabled);
570
+ }
571
+ catch (error) {
572
+ this.logger.warn(`Summary auto-update failed: ${error}`);
573
+ }
574
+ finally {
575
+ this.summaryAutoUpdateRunning = false;
576
+ }
577
+ })();
578
+ }
579
+ // ---------------------------------------------------------------------------
580
+ // Full-text index (see docs/fulltext-search.md)
581
+ // ---------------------------------------------------------------------------
582
+ /**
583
+ * Get (lazily creating) the full-text index for this database.
584
+ *
585
+ * The index is a changefeed-maintained, encrypted-at-rest search index
586
+ * over extracted document text — configured via the synced setup
587
+ * document (`dbsetup`, field `fulltextSetup`) or an explicit `config`.
588
+ * Passing a `config` on a later call replaces the configuration; if it
589
+ * differs from the active one, the engine is recreated and a resumable
590
+ * backfill re-indexes all documents on the next `update()`.
591
+ *
592
+ * Note that indexing is OPT-IN: without `enabled: true` (in the setup
593
+ * document or the explicit config) the index stays empty and
594
+ * {@link searchText} throws.
595
+ */
596
+ getFullTextIndex(config) {
597
+ if (!this.fulltextIndex) {
598
+ this.fulltextIndex = new DocumentFullTextIndex_1.DocumentFullTextIndex(this, config, {
599
+ getAttachmentExtractors: () => this.attachmentTextExtractors,
600
+ });
601
+ if (this.cacheManager) {
602
+ this.fulltextIndex.attachCache(this.cacheManager, `${this.getCachePrefix()}/fulltext`);
603
+ }
604
+ }
605
+ else if (config !== undefined) {
606
+ this.fulltextIndex.setConfig(config);
607
+ }
608
+ return this.fulltextIndex;
609
+ }
610
+ /**
611
+ * Read the full-text configuration stored in the synced setup document
612
+ * (`dbsetup`, field `fulltextSetup`). Returns `null` when no setup
613
+ * document or no configuration exists.
614
+ */
615
+ async getFulltextSetup() {
616
+ try {
617
+ const doc = await this.getDocument(types_3.DB_SETUP_DOC_ID);
618
+ return (0, types_4.sanitizeFulltextConfig)(doc.getData()[types_4.FULLTEXT_SETUP_FIELD]) ?? null;
619
+ }
620
+ catch (error) {
621
+ if (error instanceof errors_1.DocumentNotFoundError || error instanceof errors_1.DocumentDeletedError) {
622
+ return null;
623
+ }
624
+ throw error;
625
+ }
626
+ }
627
+ /**
628
+ * Write the full-text configuration into the synced setup document
629
+ * (creating `dbsetup` idempotently when missing). Passing `null`
630
+ * removes the configuration (indexing falls back to disabled).
631
+ *
632
+ * Idempotent: when the sanitized config equals what is already stored,
633
+ * no change is written — apps can safely call this on every start
634
+ * without producing changefeed/sync churn.
635
+ *
636
+ * Full-text indexes without an explicit code-provided configuration
637
+ * pick the change up through the changefeed and re-index via a
638
+ * resumable backfill — locally and, after sync, on every other replica.
639
+ */
640
+ async setFulltextSetup(config) {
641
+ const sanitized = config === null ? null : (0, types_4.sanitizeFulltextConfig)(config) ?? {};
642
+ const current = await this.getFulltextSetup();
643
+ if (JSON.stringify(current) === JSON.stringify(sanitized)) {
644
+ return;
645
+ }
646
+ const doc = await this.createDocument({ id: types_3.DB_SETUP_DOC_ID });
647
+ await this.changeDoc(doc, (d) => {
648
+ const data = d.getData();
649
+ if (sanitized === null) {
650
+ delete data[types_4.FULLTEXT_SETUP_FIELD];
651
+ }
652
+ else {
653
+ data[types_4.FULLTEXT_SETUP_FIELD] = sanitized;
654
+ }
655
+ });
656
+ }
657
+ /**
658
+ * Read the attachment extraction configuration stored in the synced
659
+ * setup document (`dbsetup`, field `extractionSetup`). Configures
660
+ * host-side extraction services (e.g. Haven's OCR service) that persist
661
+ * results at the attachment entry via
662
+ * {@link MindooDoc.setAttachmentExtractedText}. Returns `null` when no
663
+ * setup document or no configuration exists.
664
+ */
665
+ async getExtractionSetup() {
666
+ try {
667
+ const doc = await this.getDocument(types_3.DB_SETUP_DOC_ID);
668
+ return (0, types_5.sanitizeExtractionConfig)(doc.getData()[types_5.EXTRACTION_SETUP_FIELD]) ?? null;
669
+ }
670
+ catch (error) {
671
+ if (error instanceof errors_1.DocumentNotFoundError || error instanceof errors_1.DocumentDeletedError) {
672
+ return null;
673
+ }
674
+ throw error;
675
+ }
676
+ }
677
+ /**
678
+ * Write the attachment extraction configuration into the synced setup
679
+ * document (creating `dbsetup` idempotently when missing). Passing
680
+ * `null` removes the configuration (extraction services stay idle).
681
+ *
682
+ * Idempotent: when the sanitized config equals what is already stored,
683
+ * no change is written — services can safely call this on every start
684
+ * without producing changefeed/sync churn.
685
+ */
686
+ async setExtractionSetup(config) {
687
+ const sanitized = config === null ? null : (0, types_5.sanitizeExtractionConfig)(config) ?? {};
688
+ const current = await this.getExtractionSetup();
689
+ if (JSON.stringify(current) === JSON.stringify(sanitized)) {
690
+ return;
691
+ }
692
+ const doc = await this.createDocument({ id: types_3.DB_SETUP_DOC_ID });
693
+ await this.changeDoc(doc, (d) => {
694
+ const data = d.getData();
695
+ if (sanitized === null) {
696
+ delete data[types_5.EXTRACTION_SETUP_FIELD];
697
+ }
698
+ else {
699
+ data[types_5.EXTRACTION_SETUP_FIELD] = sanitized;
700
+ }
701
+ });
702
+ }
703
+ /**
704
+ * Register an attachment text extractor for full-text indexing (see
705
+ * {@link AttachmentTextExtractor}). Host environments call this when
706
+ * opening the database (e.g. Haven registers text/PDF/Office extractors
707
+ * from its preview parsers). Extraction only happens for databases
708
+ * whose `fulltextSetup` has `attachments: true`.
709
+ *
710
+ * @returns An unregister function.
711
+ */
712
+ registerAttachmentTextExtractor(extractor) {
713
+ this.attachmentTextExtractors.push(extractor);
714
+ return () => {
715
+ const index = this.attachmentTextExtractors.indexOf(extractor);
716
+ if (index >= 0) {
717
+ this.attachmentTextExtractors.splice(index, 1);
718
+ }
719
+ };
720
+ }
721
+ /**
722
+ * Full-text search over the document full-text index (see
723
+ * docs/fulltext-search.md). Brings the index up to date first, then
724
+ * returns matching documents best-score-first. Results are
725
+ * device-specific under E2EE: documents whose decryption keys are
726
+ * missing from the local KeyBag are not indexed and cannot match.
727
+ *
728
+ * @throws Error when full-text indexing is not enabled for this
729
+ * database (see {@link setFulltextSetup} / {@link getFullTextIndex}).
730
+ */
731
+ async searchText(query, options) {
732
+ return this.getFullTextIndex().search(query, options);
733
+ }
734
+ /**
735
+ * Enable/disable full-text auto-follow (default: enabled). Mirrors
736
+ * {@link setSummaryAutoUpdateEnabled}: when enabled, an ACTIVE full-text
737
+ * index catches up in the background after every coalesced change
738
+ * event, so the first search after a large sync doesn't stall.
739
+ * Databases without an enabled `fulltextSetup` never pay any cost.
740
+ */
741
+ setFulltextAutoUpdateEnabled(enabled) {
742
+ this.fulltextAutoUpdateEnabled = enabled;
743
+ if (enabled && this.fulltextIndex) {
744
+ this.scheduleFulltextAutoUpdate();
745
+ }
746
+ }
747
+ /**
748
+ * Change-event hook for full-text auto-follow. While no index exists,
749
+ * the setup document decides whether to activate one — probed on the
750
+ * first event and re-checked whenever a `dbsetup` change arrives
751
+ * (mirrors {@link handleChangesForSummaryAutoUpdate}).
752
+ */
753
+ handleChangesForFulltextAutoUpdate(changes) {
754
+ if (!this.fulltextAutoUpdateEnabled) {
755
+ return;
756
+ }
757
+ if (this.fulltextIndex) {
758
+ this.scheduleFulltextAutoUpdate();
759
+ return;
760
+ }
761
+ // Same system-database exclusion as the summary auto-activation.
762
+ if (this._isAdminOnlyDb || this.store.getId() === "directory") {
763
+ return;
764
+ }
765
+ const hasSetupChange = changes.some((change) => change.docId === types_3.DB_SETUP_DOC_ID);
766
+ if (!hasSetupChange && this.fulltextSetupProbed) {
767
+ return;
768
+ }
769
+ this.fulltextSetupProbed = true;
770
+ void this.activateFulltextFromSetupDoc();
771
+ }
772
+ /** Create the full-text index when the setup document enables one. */
773
+ async activateFulltextFromSetupDoc() {
774
+ try {
775
+ const config = await this.getFulltextSetup();
776
+ if (config?.enabled !== true ||
777
+ this.fulltextIndex ||
778
+ !this.fulltextAutoUpdateEnabled) {
779
+ return;
780
+ }
781
+ // No explicit config: the index adopts the setup-document config on
782
+ // its first update run (seedConfigFromSetupDoc).
783
+ this.getFullTextIndex();
784
+ this.scheduleFulltextAutoUpdate();
785
+ }
786
+ catch (error) {
787
+ this.logger.warn(`Full-text auto-activation failed: ${error}`);
788
+ }
789
+ }
790
+ /**
791
+ * Run a background full-text catch-up, coalescing further requests into
792
+ * one follow-up run (mirrors {@link scheduleSummaryAutoUpdate}).
793
+ */
794
+ scheduleFulltextAutoUpdate() {
795
+ if (this.fulltextAutoUpdateRunning) {
796
+ this.fulltextAutoUpdatePending = true;
797
+ return;
798
+ }
799
+ this.fulltextAutoUpdateRunning = true;
800
+ void (async () => {
801
+ try {
802
+ do {
803
+ this.fulltextAutoUpdatePending = false;
804
+ await this.fulltextIndex?.update();
805
+ } while (this.fulltextAutoUpdatePending && this.fulltextAutoUpdateEnabled);
806
+ }
807
+ catch (error) {
808
+ this.logger.warn(`Full-text auto-update failed: ${error}`);
809
+ }
810
+ finally {
811
+ this.fulltextAutoUpdateRunning = false;
812
+ }
813
+ })();
814
+ }
815
+ // ---------------------------------------------------------------------------
816
+ // Change notifications (reactive support)
817
+ // ---------------------------------------------------------------------------
818
+ /**
819
+ * Register a listener notified after documents change (local writes,
820
+ * sync ingest, access flips, witness updates). Events are coalesced:
821
+ * one event per sync batch, one per event-loop turn for local writes.
822
+ * The event carries only lightweight metadata plus the latest change
823
+ * cursor — no replay guarantee; consumers needing completeness read
824
+ * {@link iterateChangesSince} from their own cursor.
825
+ *
826
+ * @returns An unsubscribe function.
827
+ */
828
+ addChangeListener(listener) {
829
+ this.changeListeners.add(listener);
830
+ return () => {
831
+ this.changeListeners.delete(listener);
832
+ };
833
+ }
834
+ /** Record a document transition for the coalesced change event. */
835
+ noteChangeForListeners(docId, lastModified, isDeleted) {
836
+ if (this.changeListeners.size === 0) {
837
+ return;
838
+ }
839
+ this.pendingChangeNotifications.set(docId, { docId, isDeleted, lastModified });
840
+ this.scheduleChangeNotification();
841
+ }
842
+ scheduleChangeNotification() {
843
+ if (this.changeNotifyTimer !== null || this.changeNotificationHolds > 0) {
844
+ return;
845
+ }
846
+ this.changeNotifyTimer = setTimeout(() => {
847
+ this.changeNotifyTimer = null;
848
+ this.emitPendingChangeEvent();
849
+ }, 0);
850
+ }
851
+ /**
852
+ * Suppress timer-based change emission for the duration of a batch
853
+ * operation (sync ingest); the release emits one coalesced event.
854
+ */
855
+ beginChangeNotificationHold() {
856
+ this.changeNotificationHolds++;
857
+ }
858
+ endChangeNotificationHold() {
859
+ this.changeNotificationHolds = Math.max(0, this.changeNotificationHolds - 1);
860
+ if (this.changeNotificationHolds === 0) {
861
+ this.emitPendingChangeEvent();
862
+ }
863
+ }
864
+ emitPendingChangeEvent() {
865
+ if (this.changeNotificationHolds > 0) {
866
+ return;
867
+ }
868
+ if (this.changeNotifyTimer !== null) {
869
+ clearTimeout(this.changeNotifyTimer);
870
+ this.changeNotifyTimer = null;
871
+ }
872
+ if (this.pendingChangeNotifications.size === 0) {
873
+ return;
874
+ }
875
+ const changes = Array.from(this.pendingChangeNotifications.values());
876
+ this.pendingChangeNotifications.clear();
877
+ if (this.changeListeners.size === 0) {
878
+ return;
879
+ }
880
+ const event = {
881
+ changes,
882
+ cursor: this.getLatestChangeCursor(),
883
+ };
884
+ for (const listener of this.changeListeners) {
885
+ try {
886
+ listener(event);
887
+ }
888
+ catch (error) {
889
+ // Listener errors must never propagate into write/sync paths.
890
+ this.logger.warn(`Change listener threw: ${error}`);
891
+ }
892
+ }
893
+ }
894
+ /**
895
+ * Run an ad-hoc query against the document summary buffer (see
896
+ * docs/adhoc-queries.md). Filters and expression sort keys use the
897
+ * MindooDB expression language; documents are never materialized unless
898
+ * `options.allowFullScan` is set.
899
+ */
900
+ async query(query, options) {
901
+ return (0, executeQuery_1.executeQuery)(this, this.getSummaryStore(), query, options);
902
+ }
903
+ /**
904
+ * Build an ephemeral, summary-backed VirtualView (categories, sorting,
905
+ * totals as usual — but fed from the summary buffer, so no documents are
906
+ * materialized and re-sorting via `resort()` is a pure in-memory
907
+ * operation). The view is not cached; call `dispose()` when done.
908
+ */
909
+ async queryView(definition, options) {
910
+ return (0, queryView_1.createEphemeralSummaryView)(this, this.getSummaryStore(), definition, options);
911
+ }
912
+ /**
913
+ * Live query: delivers the initial result asynchronously, then keeps
914
+ * re-evaluating after coalesced change events. `onResult` only fires
915
+ * when the result fingerprint (docIds + lastModified of the matches)
916
+ * actually changed.
917
+ */
918
+ queryLive(query, onResult, options) {
919
+ return (0, queryLive_1.executeQueryLive)(this, this.getSummaryStore(), query, onResult, options);
279
920
  }
280
921
  getCachePrefix() {
281
922
  return this.cachePrefix ?? `${this.tenant.getId()}/${this.store.getId()}`;
@@ -422,12 +1063,18 @@ class BaseMindooDB {
422
1063
  * flush as well as from the upcoming flush-before-evict path.
423
1064
  */
424
1065
  async flushDirtyDocToCache(store, prefix, docId, internal) {
425
- const amBinary = automerge_adapter_1.Automerge.save(internal.doc);
426
- const indexEntryIdx = this.indexLookup.get(docId);
1066
+ const indexEntryIdx = this.getDocIndexPosition(docId);
427
1067
  const changeSeq = indexEntryIdx === undefined
428
1068
  ? 0
429
1069
  : this.index[indexEntryIdx].changeSeq;
430
1070
  const automergeHeads = automerge_adapter_1.Automerge.getHeads(internal.doc);
1071
+ // Skip the Automerge.save + L2 write when the exact same state was
1072
+ // already flushed (docs marked dirty by a load rather than a change).
1073
+ const flushKey = `${changeSeq}:${automergeHeads.join(",")}`;
1074
+ if (this.lastFlushedDocState.get(docId) === flushKey) {
1075
+ return;
1076
+ }
1077
+ const amBinary = automerge_adapter_1.Automerge.save(internal.doc);
431
1078
  const header = JSON.stringify({
432
1079
  version: DOC_CACHE_HEADER_VERSION,
433
1080
  id: internal.id,
@@ -435,6 +1082,8 @@ class BaseMindooDB {
435
1082
  lastModified: internal.lastModified,
436
1083
  decryptionKeyId: internal.decryptionKeyId,
437
1084
  isDeleted: internal.isDeleted,
1085
+ awaitingWitness: internal.awaitingWitness ?? false,
1086
+ witnessed: internal.witnessed ?? false,
438
1087
  changeSeq,
439
1088
  automergeHeads,
440
1089
  });
@@ -446,6 +1095,7 @@ class BaseMindooDB {
446
1095
  value.set(headerBytes, 4);
447
1096
  value.set(amBinary, 4 + headerBytes.length);
448
1097
  await store.put("doc", `${prefix}/${docId}`, value);
1098
+ this.lastFlushedDocState.set(docId, flushKey);
449
1099
  }
450
1100
  exportMetadataCheckpoint() {
451
1101
  const checkpoint = {
@@ -501,6 +1151,7 @@ class BaseMindooDB {
501
1151
  typeof checkpoint.keyBagFingerprint === "string" ? checkpoint.keyBagFingerprint : null;
502
1152
  // Rebuild indexLookup from index
503
1153
  this.indexLookup.clear();
1154
+ this.indexLookupStaleFrom = null;
504
1155
  for (let i = 0; i < this.index.length; i++) {
505
1156
  if (typeof this.index[i].changeSeq !== "number") {
506
1157
  this.index[i].changeSeq = i + 1;
@@ -511,6 +1162,14 @@ class BaseMindooDB {
511
1162
  if (!this.index[i].accessState) {
512
1163
  this.index[i].accessState = "visible";
513
1164
  }
1165
+ if (typeof this.index[i].awaitingWitness !== "boolean") {
1166
+ // Caches written before the witness-aware flag: treat as not awaiting.
1167
+ this.index[i].awaitingWitness = false;
1168
+ }
1169
+ if (typeof this.index[i].witnessed !== "boolean") {
1170
+ // Caches written before the witness-aware flag: treat as not witnessed.
1171
+ this.index[i].witnessed = false;
1172
+ }
514
1173
  this.indexLookup.set(this.index[i].docId, i);
515
1174
  }
516
1175
  if (this.index.length > 0 && this.nextChangeSeq <= this.index.length) {
@@ -577,6 +1236,7 @@ class BaseMindooDB {
577
1236
  const docId = batch[i].slice(docPrefix.length);
578
1237
  await store.delete("doc", batch[i]);
579
1238
  this.docCache.delete(docId);
1239
+ this.lastFlushedDocState.delete(docId);
580
1240
  continue;
581
1241
  }
582
1242
  await this.storeCachedDocument(deserialized.internal);
@@ -629,6 +1289,8 @@ class BaseMindooDB {
629
1289
  lastModified: header.lastModified,
630
1290
  decryptionKeyId: header.decryptionKeyId,
631
1291
  isDeleted: header.isDeleted,
1292
+ awaitingWitness: typeof header.awaitingWitness === "boolean" ? header.awaitingWitness : false,
1293
+ witnessed: typeof header.witnessed === "boolean" ? header.witnessed : false,
632
1294
  };
633
1295
  return {
634
1296
  internal,
@@ -664,15 +1326,91 @@ class BaseMindooDB {
664
1326
  return cryptoKey;
665
1327
  }
666
1328
  /**
667
- * Verify a signature using a pre-imported CryptoKey.
668
- * This bypasses the key import step for better performance when keys are cached.
1329
+ * Compute the author's metadata-binding signature for a new entry (audit
1330
+ * finding #5). The signature covers the canonical, versioned, length-prefixed
1331
+ * metadata layout (see crypto/EntrySignature.ts) rather than only the
1332
+ * ciphertext, so the cleartext metadata cannot be tampered with after signing.
1333
+ * Uses the supplied custom signing key when present, otherwise the current
1334
+ * user's signing key.
669
1335
  */
670
- async verifySignatureWithKey(cryptoKey, payload, signature) {
1336
+ async computeEntryMetadataSignature(meta, signing) {
1337
+ const bytes = (0, EntrySignature_1.buildEntrySigningBytes)((0, EntrySignature_1.entrySignatureFieldsFromEntry)(meta));
1338
+ if (signing?.signingKeyPair && signing?.signingKeyPassword) {
1339
+ return this.tenant.signPayloadWithKey(bytes, signing.signingKeyPair, signing.signingKeyPassword);
1340
+ }
1341
+ return this.tenant.signPayload(bytes);
1342
+ }
1343
+ /**
1344
+ * Collect the full set of attachment references a document holds right now,
1345
+ * for the signed {@link StoreEntryMetadata.attachmentRefs} snapshot.
1346
+ *
1347
+ * Refs are sorted by `attachmentId` to give a canonical, deterministic order
1348
+ * so the bytes signed by the writer match the bytes a verifier reconstructs
1349
+ * (see crypto/EntrySignature.ts). Reads the merged `_attachments` array, so
1350
+ * additions, removals, and appends are all reflected.
1351
+ */
1352
+ collectAttachmentRefs(doc) {
1353
+ const atts = doc._attachments ?? [];
1354
+ return atts
1355
+ .map((a) => ({
1356
+ attachmentId: a.attachmentId,
1357
+ lastChunkId: a.lastChunkId,
1358
+ size: a.size,
1359
+ }))
1360
+ .sort((x, y) => x.attachmentId < y.attachmentId ? -1 : x.attachmentId > y.attachmentId ? 1 : 0);
1361
+ }
1362
+ /**
1363
+ * Verify a store entry's author signature using a pre-imported CryptoKey,
1364
+ * version-aware (audit finding #5): prefers the metadata-binding
1365
+ * `metadataSignature` when present, otherwise the legacy ciphertext signature,
1366
+ * and re-verifies `SHA-256(encryptedData) === contentHash`. Returns false on
1367
+ * any mismatch. The caller is responsible for the directory trust check.
1368
+ */
1369
+ async verifyEntrySignatureWithKey(cryptoKey, entry) {
671
1370
  const subtle = this.tenant.getCryptoAdapter().getSubtle();
672
- const isValid = await subtle.verify({
673
- name: "Ed25519",
674
- }, cryptoKey, signature.buffer, payload.buffer);
675
- return isValid;
1371
+ const actualHash = await (0, idGeneration_1.computeContentHash)(entry.encryptedData, subtle);
1372
+ if (actualHash !== entry.contentHash) {
1373
+ this.logger.warn(`Content hash mismatch for entry ${entry.id}`);
1374
+ return false;
1375
+ }
1376
+ const requireMetadataSignature = await this.requiresMetadataSignature(entry);
1377
+ return (0, EntrySignature_1.verifyEntrySignatureWithImportedKey)(entry, entry.encryptedData, cryptoKey, subtle, {
1378
+ requireMetadataSignature,
1379
+ });
1380
+ }
1381
+ /**
1382
+ * Whether the tenant storage-format floor (`requireMetadataSignatureSince`)
1383
+ * requires `entry` to carry the v2 metadata-binding signature, based on its
1384
+ * trusted time. The `directory` store is exempt (it must always load so the
1385
+ * policy that defines the floor can be read); entries that already carry a
1386
+ * `metadataSignature` short-circuit. Fail-open on resolution errors is safe —
1387
+ * the legacy signature is still cryptographically verified; the floor only
1388
+ * refuses the weaker fallback for new entries.
1389
+ */
1390
+ async requiresMetadataSignature(entry) {
1391
+ if (entry.metadataSignature) {
1392
+ return false;
1393
+ }
1394
+ // Exempt the directory store from the floor: it must always materialize so
1395
+ // the policy can be read, and resolving the floor itself loads it.
1396
+ if (this.store.getId() === "directory") {
1397
+ return false;
1398
+ }
1399
+ try {
1400
+ const directory = await this.tenant.openDirectory();
1401
+ if (typeof directory.getRequireMetadataSignatureSince !== "function") {
1402
+ return false;
1403
+ }
1404
+ const cutoff = await directory.getRequireMetadataSignatureSince();
1405
+ if (cutoff === undefined) {
1406
+ return false;
1407
+ }
1408
+ return (0, storeEntryTime_1.entryTrustedTime)(entry, Date.now()) >= cutoff;
1409
+ }
1410
+ catch (error) {
1411
+ this.logger.warn(`Failed to resolve metadata-signature floor: ${String(error)}`);
1412
+ return false;
1413
+ }
676
1414
  }
677
1415
  /**
678
1416
  * Compare two index entries for sorting.
@@ -706,7 +1444,7 @@ class BaseMindooDB {
706
1444
  * @param accessState `"visible"` for readable docs, `"inaccessible"` for
707
1445
  * tombstones produced by the key visibility layer
708
1446
  */
709
- updateIndex(docId, lastModified, isDeleted, decryptionKeyId = "default", accessState = "visible") {
1447
+ updateIndex(docId, lastModified, isDeleted, decryptionKeyId = "default", accessState = "visible", awaitingWitness = false, witnessed = false) {
710
1448
  const startedAt = Date.now();
711
1449
  const assignedSeq = this.nextChangeSeq;
712
1450
  const newEntry = {
@@ -716,26 +1454,28 @@ class BaseMindooDB {
716
1454
  isDeleted,
717
1455
  decryptionKeyId,
718
1456
  accessState,
1457
+ awaitingWitness,
1458
+ witnessed,
719
1459
  };
720
- const existingIndex = this.indexLookup.get(docId);
1460
+ const existingIndex = this.getDocIndexPosition(docId);
721
1461
  // Check if the entry already exists and hasn't changed position
722
1462
  if (existingIndex !== undefined) {
723
1463
  const existingEntry = this.index[existingIndex];
724
1464
  // If only metadata flags stayed identical and caller replays same state, skip.
1465
+ // `awaitingWitness` is part of this identity so a witness receipt (which
1466
+ // changes only `receivedAt`, not Automerge content) still bumps changeSeq
1467
+ // and re-emits the doc on the metadata feed.
725
1468
  if (existingEntry.lastModified === lastModified
726
1469
  && existingEntry.isDeleted === isDeleted
727
1470
  && existingEntry.decryptionKeyId === decryptionKeyId
728
- && existingEntry.accessState === accessState) {
1471
+ && existingEntry.accessState === accessState
1472
+ && existingEntry.awaitingWitness === awaitingWitness
1473
+ && existingEntry.witnessed === witnessed) {
729
1474
  return; // No change needed
730
1475
  }
731
- // Remove from current position
1476
+ // Remove from current position; positions after it are repaired lazily
1477
+ // via indexLookupStaleFrom below.
732
1478
  this.index.splice(existingIndex, 1);
733
- // Update lookup map for entries that moved (only those after the removed position)
734
- // We'll update the full range after insertion to be safe
735
- const minAffectedIndex = Math.min(existingIndex, this.index.length);
736
- for (let i = minAffectedIndex; i < this.index.length; i++) {
737
- this.indexLookup.set(this.index[i].docId, i);
738
- }
739
1479
  this.indexLookup.delete(docId);
740
1480
  }
741
1481
  // Find insertion point using binary search to maintain sorted order
@@ -758,17 +1498,57 @@ class BaseMindooDB {
758
1498
  // Insert at the correct position
759
1499
  this.index.splice(insertIndex, 0, newEntry);
760
1500
  this.nextChangeSeq = assignedSeq + 1;
761
- // Update lookup map for entries from insertion point onwards
762
- // Only update entries that actually moved (from insertIndex to end)
763
- for (let i = insertIndex; i < this.index.length; i++) {
764
- this.indexLookup.set(this.index[i].docId, i);
765
- }
1501
+ this.indexLookup.set(docId, insertIndex);
1502
+ // Entries at/after the first splice point may have shifted. Instead of
1503
+ // rewriting the lookup map here (O(index size) per update — twice, in the
1504
+ // old implementation), record the dirty range and let readers repair it
1505
+ // lazily. A fresh insert at the tail (the common bulk-sync case) shifts
1506
+ // nothing and stays clean.
1507
+ const shiftedFrom = existingIndex !== undefined
1508
+ ? Math.min(existingIndex, insertIndex)
1509
+ : insertIndex;
1510
+ if (shiftedFrom < this.index.length - 1) {
1511
+ this.indexLookupStaleFrom = this.indexLookupStaleFrom === null
1512
+ ? shiftedFrom
1513
+ : Math.min(this.indexLookupStaleFrom, shiftedFrom);
1514
+ }
1515
+ // Reactive support: record the transition for coalesced change events.
1516
+ // Reached only when the index actually changed (identical replays
1517
+ // returned early above).
1518
+ this.noteChangeForListeners(docId, lastModified, isDeleted);
766
1519
  this.performanceCallback?.onIndexUpdate?.({
767
1520
  docId,
768
1521
  operation: existingIndex === undefined ? "insert" : "update",
769
1522
  time: Date.now() - startedAt,
770
1523
  });
771
1524
  }
1525
+ /**
1526
+ * Resolve a document's position in the sorted {@link index} through the
1527
+ * lookup map, repairing lazily-deferred staleness (see
1528
+ * {@link indexLookupStaleFrom}) only when the cached position no longer
1529
+ * matches. All `indexLookup.get` reads must go through this accessor.
1530
+ */
1531
+ getDocIndexPosition(docId) {
1532
+ const pos = this.indexLookup.get(docId);
1533
+ if (pos === undefined) {
1534
+ return undefined;
1535
+ }
1536
+ if (this.index[pos]?.docId === docId) {
1537
+ return pos;
1538
+ }
1539
+ this.flushIndexLookupPatch();
1540
+ return this.indexLookup.get(docId);
1541
+ }
1542
+ /** Rewrite the stale tail of {@link indexLookup} recorded by {@link updateIndex}. */
1543
+ flushIndexLookupPatch() {
1544
+ if (this.indexLookupStaleFrom === null) {
1545
+ return;
1546
+ }
1547
+ for (let i = this.indexLookupStaleFrom; i < this.index.length; i++) {
1548
+ this.indexLookup.set(this.index[i].docId, i);
1549
+ }
1550
+ this.indexLookupStaleFrom = null;
1551
+ }
772
1552
  /**
773
1553
  * Register an automerge hash -> entry ID mapping for a document.
774
1554
  * This is used to resolve Automerge dependency hashes to entry IDs.
@@ -779,6 +1559,22 @@ class BaseMindooDB {
779
1559
  }
780
1560
  this.automergeHashToEntryId.get(docId).set(automergeHash, entryId);
781
1561
  }
1562
+ registerAutomergeHashMappingIfAbsent(docId, automergeHash, entryId) {
1563
+ if (this.getEntryIdForAutomergeHash(docId, automergeHash)) {
1564
+ return;
1565
+ }
1566
+ this.registerAutomergeHashMapping(docId, automergeHash, entryId);
1567
+ }
1568
+ registerSnapshotHeadHashMappings(metadata) {
1569
+ if (metadata.entryType !== "doc_snapshot") {
1570
+ return;
1571
+ }
1572
+ for (const headHash of metadata.snapshotHeadHashes ?? []) {
1573
+ // A dense sync may keep only a snapshot for covered heads. In that case
1574
+ // the snapshot entry is the local causal parent for future changes.
1575
+ this.registerAutomergeHashMappingIfAbsent(metadata.docId, headHash, metadata.id);
1576
+ }
1577
+ }
782
1578
  /**
783
1579
  * Get the entry ID for an automerge hash within a document.
784
1580
  * Returns null if not found.
@@ -823,6 +1619,9 @@ class BaseMindooDB {
823
1619
  }
824
1620
  this.registerAutomergeHashMapping(docId, parsed.automergeHash, metadata.id);
825
1621
  }
1622
+ for (const metadata of allMetadata) {
1623
+ this.registerSnapshotHeadHashMappings(metadata);
1624
+ }
826
1625
  }
827
1626
  /**
828
1627
  * Resolve Automerge dependency hashes to entry IDs without silently dropping parents.
@@ -921,6 +1720,7 @@ class BaseMindooDB {
921
1720
  if (this.reconcileRestoredIndexOnInit) {
922
1721
  await this.reconcileRestoredIndexWithStore();
923
1722
  }
1723
+ this.probeFulltextSetupAtOpen();
924
1724
  return;
925
1725
  }
926
1726
  }
@@ -929,6 +1729,29 @@ class BaseMindooDB {
929
1729
  // docs whose keys are unavailable, so the only thing left to do is
930
1730
  // record the current fingerprint for the next warm start.
931
1731
  this.markKeyBagFingerprintReconciled(await this.computeCurrentKeyBagFingerprint());
1732
+ this.probeFulltextSetupAtOpen();
1733
+ }
1734
+ /**
1735
+ * Open-time probe for full-text auto-activation. Without it, a
1736
+ * database whose synced `fulltextSetup` is already enabled would not
1737
+ * build its index until the first change event arrives or the first
1738
+ * search runs. Fired once (fire-and-forget) at the end of
1739
+ * {@link initialize}; uses the same system-database exclusions and
1740
+ * activation path as the change-event probe (see
1741
+ * {@link handleChangesForFulltextAutoUpdate}).
1742
+ */
1743
+ probeFulltextSetupAtOpen() {
1744
+ if (!this.fulltextAutoUpdateEnabled || this.fulltextSetupProbed || this.fulltextIndex) {
1745
+ return;
1746
+ }
1747
+ if (this._isAdminOnlyDb || this.store.getId() === "directory") {
1748
+ return;
1749
+ }
1750
+ if (this.isTimeTravelMode()) {
1751
+ return;
1752
+ }
1753
+ this.fulltextSetupProbed = true;
1754
+ void this.activateFulltextFromSetupDoc();
932
1755
  }
933
1756
  /**
934
1757
  * Compute the current tenant KeyBag fingerprint, or `null` when the
@@ -1010,7 +1833,12 @@ class BaseMindooDB {
1010
1833
  async purgeMaterializedDocument(docId) {
1011
1834
  this.docCache.delete(docId);
1012
1835
  this.dirtyDocIds.delete(docId);
1836
+ this.lastFlushedDocState.delete(docId);
1013
1837
  this.automergeHashToEntryId.delete(docId);
1838
+ // Plaintext summary values must not outlive the purge either.
1839
+ this.summaryStore?.removeDocument(docId);
1840
+ // Nor extracted plaintext tokens in the full-text index.
1841
+ this.fulltextIndex?.removeDocument(docId);
1014
1842
  const store = this.cacheManager?.getStore();
1015
1843
  if (store) {
1016
1844
  try {
@@ -1078,23 +1906,28 @@ class BaseMindooDB {
1078
1906
  if (!visibility) {
1079
1907
  continue;
1080
1908
  }
1081
- const existingIndex = this.indexLookup.get(docId);
1909
+ const existingIndex = this.getDocIndexPosition(docId);
1082
1910
  const existing = existingIndex === undefined ? undefined : this.index[existingIndex];
1911
+ // Visibility is governed by key possession alone: documents whose
1912
+ // decryption key is in the bag are visible; the rest are hidden. Which
1913
+ // keys a user holds is governed by the key distribution model
1914
+ // (docs/accesscontrol.md §13), not by a server/client read gate.
1083
1915
  const canRead = await this.tenant.hasDecryptionKey(visibility.decryptionKeyId);
1084
1916
  if (canRead) {
1085
1917
  // Reveal-on-add only triggers when the previous state was
1086
1918
  // missing or inaccessible; visible->visible transitions stay
1087
1919
  // idempotent via the existing `updateIndex` short-circuit.
1088
1920
  if (!existing || existing.accessState === "inaccessible") {
1089
- this.updateIndex(docId, visibility.lastModified, visibility.isDeleted, visibility.decryptionKeyId, "visible");
1921
+ const { awaitingWitness, witnessed } = (0, storeEntryTime_1.metadataWitnessState)(metadataByDoc.get(docId));
1922
+ this.updateIndex(docId, visibility.lastModified, visibility.isDeleted, visibility.decryptionKeyId, "visible", awaitingWitness, witnessed);
1090
1923
  changed = true;
1091
1924
  }
1092
1925
  continue;
1093
1926
  }
1094
1927
  if (existing?.accessState === "visible") {
1095
- // Key was revoked while we still had plaintext state in cache:
1096
- // wipe it and flip the index entry to an inaccessible tombstone
1097
- // with `isDeleted: true` so view feeds emit a clean removal.
1928
+ // Key was revoked while we still had plaintext state in cache: wipe it
1929
+ // and flip the index entry to an inaccessible tombstone with
1930
+ // `isDeleted: true` so view feeds emit a clean removal.
1098
1931
  await this.purgeMaterializedDocument(docId);
1099
1932
  this.updateIndex(docId, visibility.lastModified, true, visibility.decryptionKeyId, "inaccessible");
1100
1933
  changed = true;
@@ -1133,7 +1966,9 @@ class BaseMindooDB {
1133
1966
  `Rebuilding metadata from local store.`);
1134
1967
  this.index = [];
1135
1968
  this.indexLookup.clear();
1969
+ this.indexLookupStaleFrom = null;
1136
1970
  this.docCache.clear();
1971
+ this.lastFlushedDocState.clear();
1137
1972
  this.automergeHashToEntryId.clear();
1138
1973
  this.processedEntryIds = [];
1139
1974
  this.processedEntryCursor = null;
@@ -1156,6 +1991,17 @@ class BaseMindooDB {
1156
1991
  * the workload is about to benefit from it.
1157
1992
  */
1158
1993
  async syncStoreChanges() {
1994
+ // Hold change-notification emission for the whole batch so listeners
1995
+ // receive one coalesced event per sync run instead of one per document.
1996
+ this.beginChangeNotificationHold();
1997
+ try {
1998
+ await this.syncStoreChangesInternal();
1999
+ }
2000
+ finally {
2001
+ this.endChangeNotificationHold();
2002
+ }
2003
+ }
2004
+ async syncStoreChangesInternal() {
1159
2005
  const syncStartedAt = Date.now();
1160
2006
  this.logger.debug(`Syncing store changes for database ${this.store.getId()} in tenant ${this.tenant.getId()}`);
1161
2007
  this.logger.debug(`Already processed ${this.processedEntryIds.length} entry IDs`);
@@ -1203,8 +2049,8 @@ class BaseMindooDB {
1203
2049
  if (updatedDoc) {
1204
2050
  this.logger.debug(`Successfully updated cached document ${docId} incrementally`);
1205
2051
  // Only update index if document actually changed
1206
- this.updateIndex(docId, updatedDoc.lastModified, updatedDoc.isDeleted, updatedDoc.decryptionKeyId, "visible");
1207
- this.logger.debug(`Updated index for document ${docId} (lastModified: ${updatedDoc.lastModified}, isDeleted: ${updatedDoc.isDeleted})`);
2052
+ this.updateIndex(docId, updatedDoc.lastModified, updatedDoc.isDeleted, updatedDoc.decryptionKeyId, "visible", updatedDoc.awaitingWitness ?? false, updatedDoc.witnessed ?? false);
2053
+ this.logger.debug(`Updated index for document ${docId} (lastModified: ${updatedDoc.lastModified}, isDeleted: ${updatedDoc.isDeleted}, awaitingWitness: ${updatedDoc.awaitingWitness ?? false})`);
1208
2054
  }
1209
2055
  else {
1210
2056
  this.logger.debug(`Document ${docId} unchanged after applying new entries, skipping index update`);
@@ -1216,7 +2062,7 @@ class BaseMindooDB {
1216
2062
  this.docCache.delete(docId);
1217
2063
  updatedDoc = await this.loadDocumentInternal(docId);
1218
2064
  if (updatedDoc) {
1219
- this.updateIndex(docId, updatedDoc.lastModified, updatedDoc.isDeleted, updatedDoc.decryptionKeyId, "visible");
2065
+ this.updateIndex(docId, updatedDoc.lastModified, updatedDoc.isDeleted, updatedDoc.decryptionKeyId, "visible", updatedDoc.awaitingWitness ?? false, updatedDoc.witnessed ?? false);
1220
2066
  }
1221
2067
  }
1222
2068
  }
@@ -1249,8 +2095,9 @@ class BaseMindooDB {
1249
2095
  const entriesForLastModified = mutationEntries.length > 0 ? mutationEntries : docLifecycleEntries;
1250
2096
  const lastModified = Math.max(...entriesForLastModified.map((e) => e.createdAt));
1251
2097
  const isDeleted = this.computeIsDeletedFromMetadata(allDocMetadata);
1252
- this.updateIndex(docId, lastModified, isDeleted, representativeEntry.decryptionKeyId, "visible");
1253
- this.logger.debug(`Metadata-first update for uncached doc ${docId} (lastModified: ${lastModified}, isDeleted: ${isDeleted})`);
2098
+ const { awaitingWitness, witnessed } = (0, storeEntryTime_1.metadataWitnessState)(allDocMetadata);
2099
+ this.updateIndex(docId, lastModified, isDeleted, representativeEntry.decryptionKeyId, "visible", awaitingWitness, witnessed);
2100
+ this.logger.debug(`Metadata-first update for uncached doc ${docId} (lastModified: ${lastModified}, isDeleted: ${isDeleted}, awaitingWitness: ${awaitingWitness})`);
1254
2101
  }
1255
2102
  }
1256
2103
  }
@@ -1275,7 +2122,7 @@ class BaseMindooDB {
1275
2122
  // existing index entry (if any). We do NOT need a second pass
1276
2123
  // over the underlying store - the key is missing, so we just
1277
2124
  // record an inaccessibility tombstone.
1278
- const existingIndex = this.indexLookup.get(docId);
2125
+ const existingIndex = this.getDocIndexPosition(docId);
1279
2126
  const existing = existingIndex === undefined ? undefined : this.index[existingIndex];
1280
2127
  if (existing?.accessState === "visible") {
1281
2128
  const lifecycleEntriesInBatch = entryMetadataList.filter((entry) => entry.entryType === "doc_create"
@@ -1299,7 +2146,9 @@ class BaseMindooDB {
1299
2146
  this.cacheMetaDirty = true;
1300
2147
  this.cacheManager?.markDirty();
1301
2148
  }
1302
- this.processedEntryIds.push(...entryMetadataList.map(em => em.id));
2149
+ if (!this.supportsCursorScan(this.store)) {
2150
+ this.processedEntryIds.push(...entryMetadataList.map(em => em.id));
2151
+ }
1303
2152
  return;
1304
2153
  }
1305
2154
  this.logger.error(`===== ERROR processing document ${docId} in syncStoreChanges =====`, error);
@@ -1331,8 +2180,13 @@ class BaseMindooDB {
1331
2180
  for (const [docId, entryMetadataList] of documentEntries) {
1332
2181
  await processDocument(docId, entryMetadataList);
1333
2182
  }
1334
- // Append new entry IDs to our processed list
1335
- this.processedEntryIds.push(...newEntryMetadata.map(em => em.id));
2183
+ // Append new entry IDs to our processed list. Only needed for stores
2184
+ // without cursor scan (the checkpoint persists them only in that case);
2185
+ // cursor-capable stores track progress via processedEntryCursor, so we
2186
+ // avoid growing an unbounded in-memory id list.
2187
+ if (!this.supportsCursorScan(this.store)) {
2188
+ this.processedEntryIds.push(...newEntryMetadata.map(em => em.id));
2189
+ }
1336
2190
  this.processedEntryCursor = nextCursor;
1337
2191
  this.cacheMetaDirty = true;
1338
2192
  this.cacheManager?.markDirty();
@@ -1544,7 +2398,14 @@ class BaseMindooDB {
1544
2398
  if (signal?.aborted) {
1545
2399
  return { transferred, cancelled: true };
1546
2400
  }
1547
- await targetStore.putEntries(batchEntries);
2401
+ const putResult = await targetStore.putEntries(batchEntries);
2402
+ // On push, the remote (witnessing) target returns receipts for accepted
2403
+ // entries; persist them back onto the local source so the revision feed
2404
+ // re-anchors them from the provisional head to their committed
2405
+ // `receivedAt` (docs/accesscontrol.md §5.3). Pull targets return void.
2406
+ if (Array.isArray(putResult) && putResult.length > 0 && sourceStore.applyWitnessReceipts) {
2407
+ await sourceStore.applyWitnessReceipts(putResult);
2408
+ }
1548
2409
  transferred += batchEntries.length;
1549
2410
  }
1550
2411
  catch (error) {
@@ -1794,7 +2655,12 @@ class BaseMindooDB {
1794
2655
  }
1795
2656
  const batch = missingIds.slice(offset, offset + pageSize);
1796
2657
  const entries = await sourceStore.getEntries(batch);
1797
- await targetStore.putEntries(entries);
2658
+ const putResult = await targetStore.putEntries(entries);
2659
+ // Persist any witness receipts back onto the local source (see push path
2660
+ // in transferEntriesInBatches; docs/accesscontrol.md §5.3).
2661
+ if (Array.isArray(putResult) && putResult.length > 0 && sourceStore.applyWitnessReceipts) {
2662
+ await sourceStore.applyWitnessReceipts(putResult);
2663
+ }
1798
2664
  transferred += entries.length;
1799
2665
  onProgress?.({
1800
2666
  phase: "transferring",
@@ -1875,6 +2741,105 @@ class BaseMindooDB {
1875
2741
  async createDocumentWithSigningKey(signingKeyPair, signingKeyPassword, decryptionKeyId) {
1876
2742
  return this.createDocumentInternal({ signingKeyPair, signingKeyPassword, decryptionKeyId });
1877
2743
  }
2744
+ /**
2745
+ * Bulk-create documents in a single append-only store write.
2746
+ *
2747
+ * Semantically equivalent to calling {@link createDocument} once per input,
2748
+ * with these bulk-oriented differences:
2749
+ * - All produced store entries are written through ONE `putEntries()` call,
2750
+ * so IndexedDB-backed stores commit the whole batch in a single readwrite
2751
+ * transaction instead of one per document.
2752
+ * - Custom-id documents MAY combine `id` with `initialValues`: the values are
2753
+ * applied as a separate `doc_change` entry on top of the deterministic
2754
+ * seed change, and both entries land in the same batch. (The single-create
2755
+ * path rejects this combination and requires a follow-up `changeDoc()`.)
2756
+ * - Freshly created documents are cached but intentionally NOT added to the
2757
+ * dirty-doc tracking: their entries are already durable in the store, so
2758
+ * an L1 eviction can always re-materialize them. This avoids the per-doc
2759
+ * flush-before-evict L2 write storm during large imports.
2760
+ * - Snapshot scheduling is skipped (fresh documents cannot exceed the
2761
+ * snapshot threshold).
2762
+ *
2763
+ * Existing custom-id documents keep the idempotent single-create semantics:
2764
+ * the existing document is returned (tombstones are undeleted first) and any
2765
+ * `initialValues` are applied as a follow-up change.
2766
+ *
2767
+ * Fail-fast: invalid inputs are rejected up front, and a denied ACL
2768
+ * precheck rejects the whole call before any fresh document is written.
2769
+ */
2770
+ async createDocuments(inputs) {
2771
+ this.assertWritable("createDocuments");
2772
+ if (inputs.length === 0) {
2773
+ return [];
2774
+ }
2775
+ this.logger.info(`Bulk-creating ${inputs.length} documents`);
2776
+ // Fail-fast input validation BEFORE any write: an invalid input anywhere
2777
+ // in the batch must reject the whole call without side effects.
2778
+ for (const input of inputs) {
2779
+ const options = input ?? {};
2780
+ if ((options.signingKeyPair !== undefined) !== (options.signingKeyPassword !== undefined)) {
2781
+ throw new Error("createDocuments: signingKeyPair and signingKeyPassword must be provided together");
2782
+ }
2783
+ if (options.id !== undefined && !types_1.CUSTOM_DOC_ID_REGEX.test(options.id)) {
2784
+ throw new Error(`createDocuments: invalid document id "${options.id}". ` +
2785
+ `Custom document IDs must match ${types_1.CUSTOM_DOC_ID_REGEX.source}.`);
2786
+ }
2787
+ }
2788
+ const results = new Array(inputs.length).fill(null);
2789
+ const prepared = [];
2790
+ for (let index = 0; index < inputs.length; index++) {
2791
+ const options = inputs[index] ?? {};
2792
+ if (options.id !== undefined) {
2793
+ // Metadata-only existence probe via the store's docId index (cheap even
2794
+ // on IndexedDB); avoids a full materialization for the common
2795
+ // fresh-document case.
2796
+ const existingMetadata = await this.store.findNewEntriesForDoc([], options.id);
2797
+ if (existingMetadata.length > 0) {
2798
+ // Idempotent path (rare during bulk imports): reuse the single-create
2799
+ // semantics (returns the existing doc, undeletes tombstones) and
2800
+ // apply initialValues as a follow-up change, mirroring the non-bulk
2801
+ // create + changeDoc flow.
2802
+ const { initialValues, ...rest } = options;
2803
+ const existingDoc = await this.createDocumentInternal(rest);
2804
+ const valueEntries = initialValues
2805
+ ? Object.entries(initialValues).filter(([k, v]) => !k.startsWith("_") && v !== undefined)
2806
+ : [];
2807
+ if (valueEntries.length > 0) {
2808
+ await this.changeDocInternal(existingDoc, (mutable) => {
2809
+ const data = mutable.getData();
2810
+ for (const [key, value] of valueEntries) {
2811
+ data[key] = value;
2812
+ }
2813
+ }, options.signingKeyPair, options.signingKeyPassword, options.bypassAccessControlPrecheck);
2814
+ }
2815
+ results[index] = existingDoc;
2816
+ continue;
2817
+ }
2818
+ }
2819
+ const preparedDoc = await this.prepareFreshDocumentCreate(options);
2820
+ prepared.push({ index, ...preparedDoc });
2821
+ }
2822
+ const allEntries = [];
2823
+ for (const preparedDoc of prepared) {
2824
+ allEntries.push(...preparedDoc.entries);
2825
+ }
2826
+ if (allEntries.length > 0) {
2827
+ await this.store.putEntries(allEntries);
2828
+ }
2829
+ for (const { index, internalDoc } of prepared) {
2830
+ await this.storeCachedDocument(internalDoc);
2831
+ this.updateIndex(internalDoc.id, internalDoc.lastModified, false, internalDoc.decryptionKeyId, "visible", internalDoc.awaitingWitness ?? false, internalDoc.witnessed ?? false);
2832
+ results[index] = this.wrapDocument(internalDoc);
2833
+ }
2834
+ if (prepared.length > 0) {
2835
+ // One metadata-checkpoint invalidation for the whole batch. The created
2836
+ // docs are intentionally NOT added to dirtyDocIds (see method docs).
2837
+ this.cacheMetaDirty = true;
2838
+ this.cacheManager?.markDirty();
2839
+ }
2840
+ this.logger.info(`Bulk-created ${prepared.length} documents (${inputs.length - prepared.length} already existed)`);
2841
+ return results;
2842
+ }
1878
2843
  /**
1879
2844
  * Internal method to create a new document.
1880
2845
  *
@@ -1895,13 +2860,30 @@ class BaseMindooDB {
1895
2860
  throw new Error("createDocument: signingKeyPair and signingKeyPassword must be provided together");
1896
2861
  }
1897
2862
  const useCustomSigningKey = signingKeyPair !== undefined && signingKeyPassword !== undefined;
1898
- const keyId = options.decryptionKeyId ?? "default";
2863
+ const keyId = await this.resolveCreateKeyId(options);
1899
2864
  const useCustomDocId = options.id !== undefined;
2865
+ // Sanitize caller-provided initial values: internal/reserved fields (those
2866
+ // starting with "_", e.g. "_attachments") are managed by MindooDB and must
2867
+ // not be seeded by callers. `undefined` values are treated as "not set"
2868
+ // (Automerge cannot represent undefined).
2869
+ const initialValueEntries = options.initialValues
2870
+ ? Object.entries(options.initialValues).filter(([k, v]) => !k.startsWith("_") && v !== undefined)
2871
+ : [];
2872
+ const hasInitialValues = initialValueEntries.length > 0;
1900
2873
  if (useCustomDocId) {
1901
2874
  if (!types_1.CUSTOM_DOC_ID_REGEX.test(options.id)) {
1902
2875
  throw new Error(`createDocument: invalid document id "${options.id}". ` +
1903
2876
  `Custom document IDs must match ${types_1.CUSTOM_DOC_ID_REGEX.source}.`);
1904
2877
  }
2878
+ // Custom-ID documents are seeded with a hard-coded initial change so that
2879
+ // independent replicas converge on the same Automerge hash. Baking
2880
+ // caller values into that change would diverge the hash per content, so
2881
+ // initialValues is unsupported on the custom-ID path in v1. Create the
2882
+ // document first, then apply values via changeDoc().
2883
+ if (hasInitialValues) {
2884
+ throw new Error("createDocument: initialValues is not supported together with a custom id; " +
2885
+ "create the document, then apply values with changeDoc().");
2886
+ }
1905
2887
  }
1906
2888
  // Admin-only validation: only admin key can modify data in admin-only databases
1907
2889
  if (this._isAdminOnlyDb) {
@@ -1949,7 +2931,8 @@ class BaseMindooDB {
1949
2931
  const fresh = automerge_adapter_1.Automerge.init();
1950
2932
  const [appliedDoc] = automerge_adapter_1.Automerge.applyChanges(fresh, [changeBytes]);
1951
2933
  newDoc = appliedDoc;
1952
- this.logger.debug(`Applied hard-coded initial change, heads: ${JSON.stringify(automerge_adapter_1.Automerge.getHeads(newDoc))}`);
2934
+ if (this.isDebugEnabled())
2935
+ this.logger.debug(`Applied hard-coded initial change, heads: ${JSON.stringify(automerge_adapter_1.Automerge.getHeads(newDoc))}`);
1953
2936
  }
1954
2937
  catch (error) {
1955
2938
  this.logger.error(`Error applying hard-coded initial change for document ${docId}:`, error);
@@ -1964,8 +2947,15 @@ class BaseMindooDB {
1964
2947
  // Store metadata in the document payload
1965
2948
  // We need to modify the document to ensure a change is created
1966
2949
  doc._attachments = [];
2950
+ // Seed caller-provided initial values within the same doc_create
2951
+ // change so a doc_create Tier 2 rule can evaluate them in the
2952
+ // "after" state (docs/accesscontrol.md §6.3, §9).
2953
+ for (const [key, value] of initialValueEntries) {
2954
+ doc[key] = value;
2955
+ }
1967
2956
  });
1968
- this.logger.debug(`Successfully created Automerge change, document heads: ${JSON.stringify(automerge_adapter_1.Automerge.getHeads(newDoc))}`);
2957
+ if (this.isDebugEnabled())
2958
+ this.logger.debug(`Successfully created Automerge change, document heads: ${JSON.stringify(automerge_adapter_1.Automerge.getHeads(newDoc))}`);
1969
2959
  }
1970
2960
  catch (error) {
1971
2961
  this.logger.error(`Error in Automerge.change for document ${docId}:`, error);
@@ -1979,6 +2969,23 @@ class BaseMindooDB {
1979
2969
  changeBytes = localChange;
1980
2970
  }
1981
2971
  this.logger.debug(`Got change bytes: ${changeBytes.length} bytes`);
2972
+ // Client-side write-policy precheck (docs/accesscontrol.md §9). Evaluates
2973
+ // the full Tier 1 + Tier 2 ruleset for this doc_create and throws
2974
+ // AccessDeniedError when denied, before we spend work encrypting/signing and
2975
+ // writing. The server witness remains authoritative; this only gives the
2976
+ // caller an immediate, meaningful error.
2977
+ {
2978
+ const signerKey = useCustomSigningKey
2979
+ ? signingKeyPair.publicKey
2980
+ : (await this.tenant.getCurrentUserId()).userSigningPublicKey;
2981
+ await this.assertWriteAllowed({
2982
+ op: "doc_create",
2983
+ signerKey,
2984
+ isAuthor: true,
2985
+ bypass: options.bypassAccessControlPrecheck,
2986
+ getAfterDoc: () => this.convertAutomergeToJS(newDoc),
2987
+ });
2988
+ }
1982
2989
  // Decode the change to get hash and dependencies
1983
2990
  this.logger.debug(`Decoding change to get hash and dependencies`);
1984
2991
  let decodedChange;
@@ -2019,6 +3026,12 @@ class BaseMindooDB {
2019
3026
  createdByPublicKey = currentUser.userSigningPublicKey;
2020
3027
  }
2021
3028
  this.logger.debug(`Signed payload, signature length: ${signature.length} bytes`);
3029
+ // Remember the creator's signing key so $author prechecks on subsequent
3030
+ // change/delete operations don't need a metadata scan.
3031
+ this.creatorKeyCache.set(docId, createdByPublicKey);
3032
+ // Snapshot of attachments referenced by the freshly created doc (normally
3033
+ // empty; the public create API does not add attachments inline).
3034
+ const createAttachmentRefs = this.collectAttachmentRefs(newDoc);
2022
3035
  // Create entry metadata
2023
3036
  const entryMetadata = {
2024
3037
  entryType: "doc_create",
@@ -2032,7 +3045,11 @@ class BaseMindooDB {
2032
3045
  signature,
2033
3046
  originalSize: changeBytes.length,
2034
3047
  encryptedSize: encryptedPayload.length,
3048
+ attachmentRefs: createAttachmentRefs.length > 0 ? createAttachmentRefs : undefined,
3049
+ entryVersion: types_1.CURRENT_STORE_ENTRY_VERSION,
2035
3050
  };
3051
+ // Bind the metadata with the author signature (audit finding #5).
3052
+ entryMetadata.metadataSignature = await this.computeEntryMetadataSignature(entryMetadata, useCustomSigningKey ? { signingKeyPair, signingKeyPassword } : undefined);
2036
3053
  // Create full entry object
2037
3054
  const fullEntry = {
2038
3055
  ...entryMetadata,
@@ -2042,6 +3059,12 @@ class BaseMindooDB {
2042
3059
  await this.store.putEntries([fullEntry]);
2043
3060
  // Register automerge hash -> entry ID mapping
2044
3061
  this.registerAutomergeHashMapping(docId, automergeHash, entryId);
3062
+ // A freshly created entry is versioned and un-witnessed, so the document is
3063
+ // awaiting witness until it is pushed and stamped with a `receivedAt`.
3064
+ const awaitingWitness = (0, storeEntryTime_1.isProvisional)(entryMetadata);
3065
+ // Witnessed only once a versioned entry carries a `receivedAt`; a brand-new
3066
+ // local create is versioned-but-provisional, so it is not yet witnessed.
3067
+ const witnessed = (0, storeEntryTime_1.isVersioned)(entryMetadata) && !awaitingWitness;
2045
3068
  // Create internal document representation
2046
3069
  const internalDoc = {
2047
3070
  id: docId,
@@ -2050,22 +3073,205 @@ class BaseMindooDB {
2050
3073
  lastModified: now,
2051
3074
  decryptionKeyId: keyId,
2052
3075
  isDeleted: false,
3076
+ awaitingWitness,
3077
+ witnessed,
2053
3078
  };
2054
3079
  // Update cache and index
2055
3080
  await this.storeCachedDocument(internalDoc);
2056
- this.updateIndex(docId, internalDoc.lastModified, false, internalDoc.decryptionKeyId, "visible");
3081
+ this.updateIndex(docId, internalDoc.lastModified, false, internalDoc.decryptionKeyId, "visible", awaitingWitness, witnessed);
2057
3082
  this.markDocDirty(docId);
2058
3083
  this.logger.info(`Document ${docId} created successfully`);
2059
3084
  this.logger.debug(`Document ${docId} cached and indexed (lastModified: ${internalDoc.lastModified})`);
2060
3085
  return this.wrapDocument(internalDoc);
2061
3086
  }
3087
+ /**
3088
+ * Build the store entries and in-memory state for one fresh document of a
3089
+ * {@link createDocuments} batch WITHOUT writing anything. The caller is
3090
+ * responsible for persisting the returned entries (batched `putEntries`)
3091
+ * and for cache/index registration afterwards.
3092
+ *
3093
+ * Mirrors {@link createDocumentInternal} for the fresh-document case, with
3094
+ * one bulk-only extension: a custom-id document MAY carry `initialValues`,
3095
+ * which are emitted as an extra `doc_change` entry on top of the
3096
+ * deterministic seed change so both land in the same store batch.
3097
+ */
3098
+ async prepareFreshDocumentCreate(options) {
3099
+ const { signingKeyPair, signingKeyPassword } = options;
3100
+ if ((signingKeyPair !== undefined) !== (signingKeyPassword !== undefined)) {
3101
+ throw new Error("createDocuments: signingKeyPair and signingKeyPassword must be provided together");
3102
+ }
3103
+ const useCustomSigningKey = signingKeyPair !== undefined && signingKeyPassword !== undefined;
3104
+ const keyId = await this.resolveCreateKeyId(options);
3105
+ const useCustomDocId = options.id !== undefined;
3106
+ // Sanitize caller-provided initial values: internal/reserved fields are
3107
+ // managed by MindooDB and must not be seeded by callers. `undefined`
3108
+ // values are treated as "not set" (Automerge cannot represent undefined).
3109
+ const initialValueEntries = options.initialValues
3110
+ ? Object.entries(options.initialValues).filter(([k, v]) => !k.startsWith("_") && v !== undefined)
3111
+ : [];
3112
+ // Admin-only validation: only admin key can modify data in admin-only databases
3113
+ if (this._isAdminOnlyDb) {
3114
+ const adminPublicKey = this.getAdminPublicKey();
3115
+ const signerPublicKey = useCustomSigningKey
3116
+ ? signingKeyPair.publicKey
3117
+ : (await this.tenant.getCurrentUserId()).userSigningPublicKey;
3118
+ if (signerPublicKey !== adminPublicKey) {
3119
+ throw new Error("Admin-only database: only the admin key can modify data");
3120
+ }
3121
+ }
3122
+ const docId = useCustomDocId ? options.id : (0, uuid_1.v7)();
3123
+ const now = Date.now();
3124
+ // Build the initial Automerge document and its first change bytes,
3125
+ // following the same seeding rules as the single-create path.
3126
+ let seedDoc;
3127
+ let createChangeBytes;
3128
+ if (useCustomDocId) {
3129
+ createChangeBytes = getCustomIdInitialChangeBytes();
3130
+ const fresh = automerge_adapter_1.Automerge.init();
3131
+ const [appliedDoc] = automerge_adapter_1.Automerge.applyChanges(fresh, [createChangeBytes]);
3132
+ seedDoc = appliedDoc;
3133
+ }
3134
+ else {
3135
+ const initialDoc = automerge_adapter_1.Automerge.init();
3136
+ seedDoc = automerge_adapter_1.Automerge.change(initialDoc, (doc) => {
3137
+ doc._attachments = [];
3138
+ // Seed caller-provided initial values within the same doc_create change
3139
+ // so a doc_create Tier 2 rule can evaluate them in the "after" state.
3140
+ for (const [key, value] of initialValueEntries) {
3141
+ doc[key] = value;
3142
+ }
3143
+ });
3144
+ const localChange = automerge_adapter_1.Automerge.getLastLocalChange(seedDoc);
3145
+ if (!localChange) {
3146
+ throw new Error("Failed to get change bytes from Automerge document");
3147
+ }
3148
+ createChangeBytes = localChange;
3149
+ }
3150
+ // Custom-id + initialValues (bulk-only): the deterministic seed change must
3151
+ // stay byte-identical across replicas, so the values go into a separate
3152
+ // follow-up change that ships in the same batch.
3153
+ let finalDoc = seedDoc;
3154
+ let valueChangeBytes = null;
3155
+ if (useCustomDocId && initialValueEntries.length > 0) {
3156
+ finalDoc = automerge_adapter_1.Automerge.change(seedDoc, { time: now }, (doc) => {
3157
+ for (const [key, value] of initialValueEntries) {
3158
+ doc[key] = value;
3159
+ }
3160
+ });
3161
+ const localChange = automerge_adapter_1.Automerge.getLastLocalChange(finalDoc);
3162
+ if (!localChange) {
3163
+ throw new Error("Failed to get change bytes from Automerge document");
3164
+ }
3165
+ valueChangeBytes = localChange;
3166
+ }
3167
+ // Client-side write-policy precheck (docs/accesscontrol.md §9) against the
3168
+ // document's final after-state (seed + initial values), so a doc_create
3169
+ // Tier 2 rule sees the same state it would see on the single-create path.
3170
+ {
3171
+ const signerKey = useCustomSigningKey
3172
+ ? signingKeyPair.publicKey
3173
+ : (await this.tenant.getCurrentUserId()).userSigningPublicKey;
3174
+ const afterDoc = finalDoc;
3175
+ await this.assertWriteAllowed({
3176
+ op: "doc_create",
3177
+ signerKey,
3178
+ isAuthor: true,
3179
+ bypass: options.bypassAccessControlPrecheck,
3180
+ getAfterDoc: () => this.convertAutomergeToJS(afterDoc),
3181
+ });
3182
+ }
3183
+ // Build the doc_create entry.
3184
+ const decodedCreate = automerge_adapter_1.Automerge.decodeChange(createChangeBytes);
3185
+ const createHash = decodedCreate.hash;
3186
+ const createDepHashes = decodedCreate.deps || [];
3187
+ const encryptedPayload = await this.tenant.encryptPayload(createChangeBytes, keyId);
3188
+ const contentHash = await (0, idGeneration_1.computeContentHash)(encryptedPayload, this.getSubtle());
3189
+ const entryId = await (0, idGeneration_1.generateDocEntryId)(docId, createHash, createDepHashes, this.getSubtle());
3190
+ const dependencyIds = await this.ensureAutomergeDepsResolved(docId, createDepHashes);
3191
+ let signature;
3192
+ let createdByPublicKey;
3193
+ if (useCustomSigningKey) {
3194
+ signature = await this.tenant.signPayloadWithKey(encryptedPayload, signingKeyPair, signingKeyPassword);
3195
+ createdByPublicKey = signingKeyPair.publicKey;
3196
+ }
3197
+ else {
3198
+ const currentUser = await this.tenant.getCurrentUserId();
3199
+ signature = await this.tenant.signPayload(encryptedPayload);
3200
+ createdByPublicKey = currentUser.userSigningPublicKey;
3201
+ }
3202
+ // Remember the creator's signing key so $author prechecks on subsequent
3203
+ // change/delete operations don't need a metadata scan.
3204
+ this.creatorKeyCache.set(docId, createdByPublicKey);
3205
+ const createAttachmentRefs = this.collectAttachmentRefs(finalDoc);
3206
+ const entryMetadata = {
3207
+ entryType: "doc_create",
3208
+ id: entryId,
3209
+ contentHash,
3210
+ docId,
3211
+ dependencyIds,
3212
+ createdAt: now,
3213
+ createdByPublicKey,
3214
+ decryptionKeyId: keyId,
3215
+ signature,
3216
+ originalSize: createChangeBytes.length,
3217
+ encryptedSize: encryptedPayload.length,
3218
+ attachmentRefs: createAttachmentRefs.length > 0 ? createAttachmentRefs : undefined,
3219
+ entryVersion: types_1.CURRENT_STORE_ENTRY_VERSION,
3220
+ };
3221
+ // Bind the metadata with the author signature (audit finding #5).
3222
+ entryMetadata.metadataSignature = await this.computeEntryMetadataSignature(entryMetadata, useCustomSigningKey ? { signingKeyPair, signingKeyPassword } : undefined);
3223
+ const entries = [{
3224
+ ...entryMetadata,
3225
+ encryptedData: encryptedPayload,
3226
+ }];
3227
+ // Register the mapping BEFORE building the follow-up change entry so its
3228
+ // dependency resolution finds the doc_create parent.
3229
+ this.registerAutomergeHashMapping(docId, createHash, entryId);
3230
+ if (valueChangeBytes) {
3231
+ const tempInternalDoc = {
3232
+ id: docId,
3233
+ doc: finalDoc,
3234
+ createdAt: now,
3235
+ lastModified: now,
3236
+ decryptionKeyId: keyId,
3237
+ isDeleted: false,
3238
+ };
3239
+ const changeEntry = await this.buildDocChangeStoreEntry({
3240
+ internalDoc: tempInternalDoc,
3241
+ changeBytes: valueChangeBytes,
3242
+ now,
3243
+ useCustomKey: useCustomSigningKey,
3244
+ signingKeyPair,
3245
+ signingKeyPassword,
3246
+ attachmentRefs: createAttachmentRefs,
3247
+ });
3248
+ entries.push(changeEntry);
3249
+ const decodedValueChange = automerge_adapter_1.Automerge.decodeChange(valueChangeBytes);
3250
+ this.registerAutomergeHashMapping(docId, decodedValueChange.hash, changeEntry.id);
3251
+ }
3252
+ // Freshly created entries are versioned and un-witnessed, so the document
3253
+ // is awaiting witness until pushed and stamped with a receipt.
3254
+ const awaitingWitness = entries.some(storeEntryTime_1.isProvisional);
3255
+ const witnessed = entries.some(storeEntryTime_1.isVersioned) && !awaitingWitness;
3256
+ const internalDoc = {
3257
+ id: docId,
3258
+ doc: finalDoc,
3259
+ createdAt: now,
3260
+ lastModified: now,
3261
+ decryptionKeyId: keyId,
3262
+ isDeleted: false,
3263
+ awaitingWitness,
3264
+ witnessed,
3265
+ };
3266
+ return { internalDoc, entries };
3267
+ }
2062
3268
  async getDocument(docId) {
2063
3269
  const internalDoc = await this.loadDocumentInternal(docId);
2064
3270
  if (!internalDoc) {
2065
- throw new Error(`Document ${docId} not found`);
3271
+ throw new errors_1.DocumentNotFoundError(docId);
2066
3272
  }
2067
3273
  if (internalDoc.isDeleted) {
2068
- throw new Error(`Document ${docId} has been deleted`);
3274
+ throw new errors_1.DocumentDeletedError(docId);
2069
3275
  }
2070
3276
  return this.wrapDocument(internalDoc);
2071
3277
  }
@@ -2115,7 +3321,7 @@ class BaseMindooDB {
2115
3321
  this.logger.warn(`Admin-only DB: skipping snapshot ${snapshotData.id} not signed by admin key`);
2116
3322
  }
2117
3323
  else {
2118
- isValid = await this.tenant.verifySignature(snapshotData.encryptedData, snapshotData.signature, snapshotData.createdByPublicKey);
3324
+ isValid = await this.tenant.verifyEntrySignature(snapshotData, snapshotData.encryptedData);
2119
3325
  }
2120
3326
  if (!isValid) {
2121
3327
  this.logger.warn(`Invalid signature for snapshot ${snapshotData.id}, falling back to replay without snapshot`);
@@ -2124,9 +3330,20 @@ class BaseMindooDB {
2124
3330
  else {
2125
3331
  const decryptedSnapshot = await this.tenant.decryptPayload(snapshotData.encryptedData, snapshotData.decryptionKeyId);
2126
3332
  doc = automerge_adapter_1.Automerge.load(decryptedSnapshot);
2127
- const parsed = (0, idGeneration_1.parseDocEntryId)(snapshotData.id);
2128
- if (parsed) {
2129
- this.registerAutomergeHashMapping(docId, parsed.automergeHash, snapshotData.id);
3333
+ // Snapshot-head verification (docs/accesscontrol.md §10): decoded heads
3334
+ // must equal the declared snapshotHeadHashes or the snapshot is
3335
+ // discarded and the doc is replayed from individually-verified entries.
3336
+ if (!(0, materializationGuard_1.snapshotHeadsMatch)(automerge_adapter_1.Automerge.getHeads(doc), snapshotData.snapshotHeadHashes)) {
3337
+ this.logger.warn(`Snapshot ${snapshotData.id} heads do not match declared snapshotHeadHashes, falling back to replay without snapshot`);
3338
+ doc = undefined;
3339
+ startFromSnapshot = false;
3340
+ }
3341
+ else {
3342
+ const parsed = (0, idGeneration_1.parseDocEntryId)(snapshotData.id);
3343
+ if (parsed) {
3344
+ this.registerAutomergeHashMapping(docId, parsed.automergeHash, snapshotData.id);
3345
+ }
3346
+ this.registerSnapshotHeadHashMappings(snapshotData);
2130
3347
  }
2131
3348
  }
2132
3349
  }
@@ -2151,7 +3368,7 @@ class BaseMindooDB {
2151
3368
  this.logger.warn(`Admin-only DB: skipping entry ${entryData.id} not signed by admin key`);
2152
3369
  continue;
2153
3370
  }
2154
- const isValid = await this.tenant.verifySignature(entryData.encryptedData, entryData.signature, entryData.createdByPublicKey);
3371
+ const isValid = await this.tenant.verifyEntrySignature(entryData, entryData.encryptedData);
2155
3372
  if (!isValid) {
2156
3373
  this.logger.warn(`Invalid signature for entry ${entryData.id}, skipping`);
2157
3374
  continue;
@@ -2221,7 +3438,7 @@ class BaseMindooDB {
2221
3438
  continue;
2222
3439
  }
2223
3440
  // Verify signature
2224
- const isValid = await this.tenant.verifySignature(entryData.encryptedData, entryData.signature, entryData.createdByPublicKey);
3441
+ const isValid = await this.tenant.verifyEntrySignature(entryData, entryData.encryptedData);
2225
3442
  if (!isValid) {
2226
3443
  this.logger.warn(`Invalid signature for entry ${entryData.id}, skipping`);
2227
3444
  continue;
@@ -2289,6 +3506,462 @@ class BaseMindooDB {
2289
3506
  }
2290
3507
  }
2291
3508
  }
3509
+ async *iterateChangeRevisionsSince(cursor) {
3510
+ yield* this.iterateRevisionsInternal(null, cursor);
3511
+ }
3512
+ async *iterateDocRevisionsSince(docId, cursor) {
3513
+ yield* this.iterateRevisionsInternal(docId, cursor);
3514
+ }
3515
+ /**
3516
+ * Shared implementation of the revision-grain changefeed
3517
+ * ({@link iterateChangeRevisionsSince} / {@link iterateDocRevisionsSince}).
3518
+ *
3519
+ * Two distinct orderings drive the feed (docs/accesscontrol.md §8):
3520
+ *
3521
+ * - **Discovery (receiptOrder).** New entries are discovered via the store's
3522
+ * gap-free `scanEntriesSince(cursor)` scan in `(receiptOrder, id)` order, so
3523
+ * an incremental advance reads only entries appended since the cursor and
3524
+ * never re-scans cold documents. The resumable {@link RevisionCursor} is this
3525
+ * scan position, parked just before the earliest un-witnessed entry so the
3526
+ * provisional head overlay is re-evaluated on every resume.
3527
+ * - **Materialization (trusted time).** Each affected document's changes are
3528
+ * folded in trusted-time order, so every yielded revision carries the
3529
+ * document's *trusted-time-bounded merge* — the merge of all of the doc's
3530
+ * changes with `trustedTime <= this revision's trustedTime`. This is correct
3531
+ * at the head and at every intermediate node, and concurrent same-base
3532
+ * changes merge from the larger-trusted-time instant onward.
3533
+ *
3534
+ * Trusted-time rule: witnessed entries use their witness `receivedAt`;
3535
+ * un-witnessed local entries use the current wall clock, so they always sit at
3536
+ * the provisional head and never appear in past-time reads until witnessed.
3537
+ *
3538
+ * An in-memory per-doc LRU ({@link revisionFeedDocCache}) lets the common
3539
+ * in-order advance seed from cached state and decrypt only the new suffix; a
3540
+ * cache miss reconstructs the seed from the best snapshot whose coverage is
3541
+ * within the fold prefix via {@link computeDocumentMaterializationPlan}.
3542
+ */
3543
+ async *iterateRevisionsInternal(docIdFilter, cursor) {
3544
+ // ── Phase 1: discover new entries in receiptOrder ─────────────────────
3545
+ const { newEntries, resumeCursor } = await this.discoverRevisionEntries(docIdFilter, cursor);
3546
+ if (newEntries.length === 0) {
3547
+ return;
3548
+ }
3549
+ // Trusted time for un-witnessed entries is the current wall clock, computed
3550
+ // once per run so all un-witnessed entries share a stable head instant.
3551
+ const now = Date.now();
3552
+ // Affected documents (those with at least one newly-discovered entry), and
3553
+ // the set of new entry ids per doc (the trusted-time positions from which the
3554
+ // doc's merged state may have changed and must be re-folded / re-emitted).
3555
+ const newIdsByDoc = new Map();
3556
+ for (const em of newEntries) {
3557
+ let set = newIdsByDoc.get(em.docId);
3558
+ if (!set) {
3559
+ set = new Set();
3560
+ newIdsByDoc.set(em.docId, set);
3561
+ }
3562
+ set.add(em.id);
3563
+ }
3564
+ // ── Phase 2: per affected doc, fold in trusted-time order, emit suffix ──
3565
+ for (const docId of Array.from(newIdsByDoc.keys()).sort()) {
3566
+ yield* this.foldDocRevisions(docId, newIdsByDoc.get(docId), now, resumeCursor);
3567
+ }
3568
+ }
3569
+ /**
3570
+ * Discovery phase of {@link iterateRevisionsInternal}: walk the store in
3571
+ * `(receiptOrder, id)` order from `cursor`, collecting the newly-appended
3572
+ * replay entries and computing the resume cursor.
3573
+ *
3574
+ * The resume cursor is the witnessed stable-prefix watermark: it advances over
3575
+ * leading witnessed entries but stops just before the first un-witnessed entry,
3576
+ * so un-witnessed entries (whose trusted time is provisional `now`) are
3577
+ * re-discovered and re-evaluated as a head overlay on the next resume.
3578
+ */
3579
+ async discoverRevisionEntries(docIdFilter, cursor) {
3580
+ const replayTypes = [
3581
+ "doc_create",
3582
+ "doc_change",
3583
+ "doc_delete",
3584
+ "doc_undelete",
3585
+ ];
3586
+ const newEntries = [];
3587
+ // Never regress below the input cursor; only advance while still in the
3588
+ // leading witnessed prefix.
3589
+ let stableCursor = cursor;
3590
+ let sawProvisional = false;
3591
+ const consider = (em) => {
3592
+ if (!this.isDocumentReplayEntry(em)) {
3593
+ return;
3594
+ }
3595
+ // Admin-only mode: only accept entries signed by the admin key (mirrors
3596
+ // materialization / iterateDocumentHistory). Non-admin entries are
3597
+ // permanently ignored and do not gate the stable cursor.
3598
+ if (this._isAdminOnlyDb && em.createdByPublicKey !== this.getAdminPublicKey()) {
3599
+ return;
3600
+ }
3601
+ newEntries.push(em);
3602
+ // The resume cursor may only advance through the leading prefix of
3603
+ // STABLE entries (whose trusted time never moves): witnessed entries and
3604
+ // legacy un-witnessed entries (stable at createdAt). It must park before
3605
+ // the first PROVISIONAL entry (versioned + un-witnessed, trusted time =
3606
+ // now), because that entry's position will change once it is witnessed
3607
+ // and re-delivered with a fresh receiptOrder. See {@link isProvisional}.
3608
+ const provisional = (0, storeEntryTime_1.isProvisional)(em);
3609
+ if (!sawProvisional) {
3610
+ if (!provisional && em.receiptOrder !== undefined) {
3611
+ stableCursor = { receiptOrder: em.receiptOrder, id: em.id };
3612
+ }
3613
+ else if (provisional) {
3614
+ sawProvisional = true;
3615
+ }
3616
+ }
3617
+ };
3618
+ if (this.supportsCursorScan(this.store)) {
3619
+ const baseFilters = { entryTypes: replayTypes };
3620
+ if (docIdFilter) {
3621
+ baseFilters.docId = docIdFilter;
3622
+ }
3623
+ const scanFilters = this.mergeTimeTravelScanFilters(baseFilters);
3624
+ let scanCursor = cursor
3625
+ ? { receiptOrder: cursor.receiptOrder, id: cursor.id }
3626
+ : null;
3627
+ while (true) {
3628
+ const page = await this.store.scanEntriesSince(scanCursor, 1000, scanFilters);
3629
+ for (const em of page.entries) {
3630
+ consider(em);
3631
+ }
3632
+ scanCursor = page.nextCursor;
3633
+ if (!page.hasMore) {
3634
+ break;
3635
+ }
3636
+ }
3637
+ }
3638
+ else {
3639
+ // Fallback for stores without cursor scan: scan all metadata, order by
3640
+ // (receiptOrder, id), and take entries strictly after the cursor.
3641
+ const filters = docIdFilter
3642
+ ? { docId: docIdFilter }
3643
+ : undefined;
3644
+ const all = (await this.scanAllMetadata(this.store, filters))
3645
+ .filter((em) => this.isDocumentReplayEntry(em))
3646
+ .sort((a, b) => this.compareReceiptOrder(a, b));
3647
+ for (const em of all) {
3648
+ if (cursor && !this.isAfterReceiptCursor(em, cursor)) {
3649
+ continue;
3650
+ }
3651
+ consider(em);
3652
+ }
3653
+ }
3654
+ return { newEntries, resumeCursor: stableCursor };
3655
+ }
3656
+ /** Compare two entries by `(receiptOrder, id)` ascending. */
3657
+ compareReceiptOrder(a, b) {
3658
+ const ra = a.receiptOrder ?? 0;
3659
+ const rb = b.receiptOrder ?? 0;
3660
+ return ra !== rb ? ra - rb : a.id.localeCompare(b.id);
3661
+ }
3662
+ /** True when `em` is strictly after `cursor` in `(receiptOrder, id)` order. */
3663
+ isAfterReceiptCursor(em, cursor) {
3664
+ const ro = em.receiptOrder ?? 0;
3665
+ if (ro !== cursor.receiptOrder) {
3666
+ return ro > cursor.receiptOrder;
3667
+ }
3668
+ return em.id.localeCompare(cursor.id) > 0;
3669
+ }
3670
+ /**
3671
+ * Materialization phase for a single affected document: fold its changes in
3672
+ * trusted-time order and yield one revision per change from the earliest
3673
+ * newly-discovered (or superseded) position onward, each carrying the
3674
+ * trusted-time-bounded merged document state.
3675
+ */
3676
+ async *foldDocRevisions(docId, newIds, now, resumeCursor) {
3677
+ // All metadata for this doc (metadata only, cheap). Includes snapshots, used
3678
+ // for cache-miss seed reconstruction.
3679
+ const docAllMeta = await this.scanAllMetadata(this.store, { docId });
3680
+ let replayMeta = docAllMeta.filter((em) => this.isDocumentReplayEntry(em));
3681
+ if (this._isAdminOnlyDb) {
3682
+ const adminKey = this.getAdminPublicKey();
3683
+ replayMeta = replayMeta.filter((em) => em.createdByPublicKey === adminKey);
3684
+ }
3685
+ if (replayMeta.length === 0) {
3686
+ return;
3687
+ }
3688
+ // Order the doc's changes by trusted time (version-aware rule, see
3689
+ // {@link entryTrustedTime}): witnessed -> receivedAt; versioned un-witnessed
3690
+ // floats to the provisional head (now); legacy un-witnessed stays at its
3691
+ // stable createdAt.
3692
+ const ttSorted = replayMeta
3693
+ .map((meta) => ({
3694
+ meta,
3695
+ tt: (0, storeEntryTime_1.entryTrustedTime)(meta, now),
3696
+ witnessed: meta.receivedAt !== undefined,
3697
+ }))
3698
+ .sort((a, b) => (a.tt !== b.tt ? a.tt - b.tt : a.meta.id.localeCompare(b.meta.id)));
3699
+ // First trusted-time position touched by a newly-discovered entry. From here
3700
+ // the doc's merged state (and every later revision) may have changed, so we
3701
+ // re-fold and re-emit the suffix; earlier revisions are unchanged.
3702
+ const firstChangedIdx = ttSorted.findIndex((r) => newIds.has(r.meta.id));
3703
+ if (firstChangedIdx < 0) {
3704
+ return;
3705
+ }
3706
+ // Origin time and key id come from the document's create entry, independent
3707
+ // of fold order (under the trusted-time rule un-witnessed entries can sort
3708
+ // before the create, so we must not rely on seeing the create first).
3709
+ const createMeta = replayMeta.find((m) => m.entryType === "doc_create");
3710
+ // Seed the running doc to the merge of the unchanged prefix.
3711
+ const prefix = ttSorted.slice(0, firstChangedIdx).map((r) => r.meta);
3712
+ const seed = await this.seedRevisionFold(docId, prefix, docAllMeta);
3713
+ let doc = seed.doc ?? automerge_adapter_1.Automerge.init();
3714
+ const createdAt = createMeta?.createdAt ?? seed.createdAt ?? 0;
3715
+ const decryptionKeyId = createMeta?.decryptionKeyId ?? seed.decryptionKeyId ?? "default";
3716
+ // Cumulative metadata up to and including each revision (deletion state).
3717
+ const seenMeta = [...prefix];
3718
+ // Load + verify + decrypt the suffix in trusted-time-order windows: one
3719
+ // getEntries round trip per window instead of one per revision (a real win
3720
+ // for network/on-disk stores), and verify/decrypt the whole window in
3721
+ // parallel (independent per-entry crypto). Folding (applyChanges) below
3722
+ // still runs strictly sequentially in trusted-time order. The window also
3723
+ // bounds peak memory and response size (the suffix is the whole history on a
3724
+ // snapshot-less cold-start re-fold).
3725
+ let preparedById = new Map();
3726
+ let windowEnd = firstChangedIdx;
3727
+ for (let i = firstChangedIdx; i < ttSorted.length; i++) {
3728
+ const rev = ttSorted[i];
3729
+ const meta = rev.meta;
3730
+ if (i >= windowEnd) {
3731
+ const windowMetas = ttSorted.slice(i, i + this.revisionFoldLoadWindow);
3732
+ windowEnd = i + windowMetas.length;
3733
+ const batch = await this.store.getEntries(windowMetas.map((r) => r.meta.id));
3734
+ const prepared = await Promise.all(batch.map(async (entry) => {
3735
+ const isValid = await this.tenant.verifyEntrySignature(entry, entry.encryptedData);
3736
+ if (!isValid) {
3737
+ this.logger.warn(`Invalid signature for revision entry ${entry.id}, skipping`);
3738
+ return null;
3739
+ }
3740
+ const payload = await this.tenant.decryptPayload(entry.encryptedData, entry.decryptionKeyId);
3741
+ return { entry, payload };
3742
+ }));
3743
+ preparedById = new Map();
3744
+ for (const p of prepared) {
3745
+ if (p) {
3746
+ preparedById.set(p.entry.id, p);
3747
+ }
3748
+ }
3749
+ }
3750
+ const ready = preparedById.get(meta.id);
3751
+ if (!ready) {
3752
+ // Absent from the loaded window means missing in the store or it failed
3753
+ // signature verification (already logged during the parallel prepare).
3754
+ this.logger.warn(`Revision entry ${meta.id} unavailable (missing or invalid), skipping`);
3755
+ continue;
3756
+ }
3757
+ const entryData = ready.entry;
3758
+ const decryptedPayload = ready.payload;
3759
+ // applyChanges buffers changes with unmet causal deps until they arrive,
3760
+ // so a change folded before its doc_create (possible under the trusted-time
3761
+ // rule for same-instant un-witnessed entries) still converges.
3762
+ const [appliedDoc] = automerge_adapter_1.Automerge.applyChanges(doc, [decryptedPayload]);
3763
+ doc = appliedDoc;
3764
+ const parsed = (0, idGeneration_1.parseDocEntryId)(entryData.id);
3765
+ if (parsed) {
3766
+ this.registerAutomergeHashMapping(docId, parsed.automergeHash, entryData.id);
3767
+ }
3768
+ seenMeta.push(meta);
3769
+ const internalDoc = {
3770
+ id: docId,
3771
+ doc: automerge_adapter_1.Automerge.clone(doc),
3772
+ createdAt,
3773
+ lastModified: meta.createdAt,
3774
+ decryptionKeyId,
3775
+ // Deletion as of this revision = latest reachable lifecycle entry among
3776
+ // this document's replay entries seen up to and including this one.
3777
+ isDeleted: this.computeIsDeletedFromMetadata([...seenMeta]),
3778
+ };
3779
+ yield {
3780
+ docId,
3781
+ entryId: meta.id,
3782
+ doc: this.wrapDocument(internalDoc),
3783
+ entryType: meta.entryType,
3784
+ trustedTime: rev.tt,
3785
+ witnessed: rev.witnessed,
3786
+ createdByPublicKey: meta.createdByPublicKey,
3787
+ cursor: resumeCursor,
3788
+ };
3789
+ }
3790
+ // Cache the doc's full merged state for the next incremental advance.
3791
+ this.putRevisionFeedCache(docId, {
3792
+ doc,
3793
+ coveredIds: new Set(ttSorted.map((r) => r.meta.id)),
3794
+ createdAt,
3795
+ decryptionKeyId,
3796
+ });
3797
+ }
3798
+ /**
3799
+ * Reconstruct the merged state of a document covering exactly the replay
3800
+ * entries in `prefix` (the unchanged head of a trusted-time fold).
3801
+ *
3802
+ * Fast path: if the LRU holds a merged doc whose coverage is a subset of the
3803
+ * prefix, clone it and apply only the missing entries. Otherwise reconstruct
3804
+ * from the best snapshot whose coverage is fully within the prefix (so the
3805
+ * snapshot can never pull in changes beyond the fold point) plus the uncovered
3806
+ * prefix tail, via {@link computeDocumentMaterializationPlan}.
3807
+ */
3808
+ async seedRevisionFold(docId, prefix, docAllMeta) {
3809
+ if (prefix.length === 0) {
3810
+ return { doc: null, createdAt: null, decryptionKeyId: null };
3811
+ }
3812
+ const createEntry = prefix.find((m) => m.entryType === "doc_create");
3813
+ const createdAt = createEntry ? createEntry.createdAt : null;
3814
+ const decryptionKeyId = createEntry ? createEntry.decryptionKeyId : null;
3815
+ const prefixIds = new Set(prefix.map((m) => m.id));
3816
+ // LRU fast path: cached coverage is a subset of the prefix -> reuse + apply
3817
+ // only the missing entries (no snapshot/genesis reconstruction).
3818
+ const cached = this.getRevisionFeedCache(docId);
3819
+ if (cached && this.isIdSubset(cached.coveredIds, prefixIds)) {
3820
+ const missing = prefix.filter((m) => !cached.coveredIds.has(m.id));
3821
+ const doc = await this.applyChangeEntries(docId, automerge_adapter_1.Automerge.clone(cached.doc), missing);
3822
+ return {
3823
+ doc,
3824
+ createdAt: createdAt ?? cached.createdAt,
3825
+ decryptionKeyId: decryptionKeyId ?? cached.decryptionKeyId,
3826
+ };
3827
+ }
3828
+ // Cache miss: reconstruct from the best in-prefix snapshot + uncovered tail.
3829
+ const prefixSnapshots = docAllMeta.filter((m) => m.entryType === "doc_snapshot" && this.snapshotRootsWithin(m, prefixIds));
3830
+ const planInput = [...prefix, ...prefixSnapshots];
3831
+ const plan = (0, MaterializationPlanner_1.computeDocumentMaterializationPlan)(docId, planInput);
3832
+ const metadataById = new Map(planInput.map((m) => [m.id, m]));
3833
+ let doc = null;
3834
+ if (plan.snapshotEntryId) {
3835
+ const snapMeta = metadataById.get(plan.snapshotEntryId);
3836
+ if (snapMeta) {
3837
+ doc = await this.loadSnapshotForFold(docId, snapMeta);
3838
+ }
3839
+ }
3840
+ if (!doc) {
3841
+ doc = automerge_adapter_1.Automerge.init();
3842
+ }
3843
+ const tail = plan.entryIdsToApply
3844
+ .map((id) => metadataById.get(id))
3845
+ .filter((m) => m !== undefined);
3846
+ doc = await this.applyChangeEntries(docId, doc, tail);
3847
+ return { doc, createdAt, decryptionKeyId };
3848
+ }
3849
+ /** True when every id in `sub` is contained in `sup`. */
3850
+ isIdSubset(sub, sup) {
3851
+ if (sub.size > sup.size) {
3852
+ return false;
3853
+ }
3854
+ for (const id of sub) {
3855
+ if (!sup.has(id)) {
3856
+ return false;
3857
+ }
3858
+ }
3859
+ return true;
3860
+ }
3861
+ /**
3862
+ * True when all of a snapshot's coverage roots are within `idSet`, i.e. the
3863
+ * snapshot represents a state reachable from entries inside the set (safe to
3864
+ * use as a seed without pulling in later changes).
3865
+ */
3866
+ snapshotRootsWithin(snapshotMeta, idSet) {
3867
+ const roots = snapshotMeta.snapshotHeadEntryIds && snapshotMeta.snapshotHeadEntryIds.length > 0
3868
+ ? snapshotMeta.snapshotHeadEntryIds
3869
+ : snapshotMeta.dependencyIds;
3870
+ if (!roots || roots.length === 0) {
3871
+ return false;
3872
+ }
3873
+ return roots.every((r) => idSet.has(r));
3874
+ }
3875
+ /** Load, verify, and decrypt a snapshot into an Automerge doc, or `null`. */
3876
+ async loadSnapshotForFold(docId, snapshotMeta) {
3877
+ const entries = await this.store.getEntries([snapshotMeta.id]);
3878
+ if (entries.length === 0) {
3879
+ return null;
3880
+ }
3881
+ const snapshotData = entries[0];
3882
+ if (this._isAdminOnlyDb && snapshotData.createdByPublicKey !== this.getAdminPublicKey()) {
3883
+ this.logger.warn(`Admin-only DB: skipping snapshot ${snapshotData.id} not signed by admin key`);
3884
+ return null;
3885
+ }
3886
+ const isValid = await this.tenant.verifyEntrySignature(snapshotData, snapshotData.encryptedData);
3887
+ if (!isValid) {
3888
+ this.logger.warn(`Invalid signature for snapshot ${snapshotData.id}, falling back to replay`);
3889
+ return null;
3890
+ }
3891
+ const decryptedSnapshot = await this.tenant.decryptPayload(snapshotData.encryptedData, snapshotData.decryptionKeyId);
3892
+ const doc = automerge_adapter_1.Automerge.load(decryptedSnapshot);
3893
+ // Mandatory snapshot-head verification (docs/accesscontrol.md §10): the
3894
+ // decoded snapshot's Automerge heads must exactly equal the covered heads
3895
+ // declared in the signed metadata, so a snapshot cannot smuggle content the
3896
+ // author was not allowed to write. On mismatch, fall back to replay.
3897
+ if (!(0, materializationGuard_1.snapshotHeadsMatch)(automerge_adapter_1.Automerge.getHeads(doc), snapshotData.snapshotHeadHashes)) {
3898
+ this.logger.warn(`Snapshot ${snapshotData.id} heads do not match declared snapshotHeadHashes, falling back to replay`);
3899
+ return null;
3900
+ }
3901
+ const parsed = (0, idGeneration_1.parseDocEntryId)(snapshotData.id);
3902
+ if (parsed) {
3903
+ this.registerAutomergeHashMapping(docId, parsed.automergeHash, snapshotData.id);
3904
+ }
3905
+ this.registerSnapshotHeadHashMappings(snapshotData);
3906
+ return doc;
3907
+ }
3908
+ /**
3909
+ * Load, verify, decrypt, and apply a set of replay change entries onto `doc`.
3910
+ * `Automerge.applyChanges` buffers changes whose causal dependencies are not
3911
+ * yet present, so the entries need not be supplied in dependency order.
3912
+ */
3913
+ async applyChangeEntries(docId, doc, metas) {
3914
+ if (metas.length === 0) {
3915
+ return doc;
3916
+ }
3917
+ const loaded = await this.store.getEntries(metas.map((m) => m.id));
3918
+ const byId = new Map(loaded.map((e) => [e.id, e]));
3919
+ const changes = [];
3920
+ for (const meta of metas) {
3921
+ const entryData = byId.get(meta.id);
3922
+ if (!entryData) {
3923
+ this.logger.warn(`Revision entry ${meta.id} not found in store, skipping`);
3924
+ continue;
3925
+ }
3926
+ const isValid = await this.tenant.verifyEntrySignature(entryData, entryData.encryptedData);
3927
+ if (!isValid) {
3928
+ this.logger.warn(`Invalid signature for revision entry ${entryData.id}, skipping`);
3929
+ continue;
3930
+ }
3931
+ const decrypted = await this.tenant.decryptPayload(entryData.encryptedData, entryData.decryptionKeyId);
3932
+ changes.push(decrypted);
3933
+ const parsed = (0, idGeneration_1.parseDocEntryId)(entryData.id);
3934
+ if (parsed) {
3935
+ this.registerAutomergeHashMapping(docId, parsed.automergeHash, entryData.id);
3936
+ }
3937
+ }
3938
+ if (changes.length === 0) {
3939
+ return doc;
3940
+ }
3941
+ const [applied] = automerge_adapter_1.Automerge.applyChanges(doc, changes);
3942
+ return applied;
3943
+ }
3944
+ /** Touch the revision-feed LRU, moving `docId` to most-recently-used. */
3945
+ getRevisionFeedCache(docId) {
3946
+ const state = this.revisionFeedDocCache.get(docId);
3947
+ if (state) {
3948
+ this.revisionFeedDocCache.delete(docId);
3949
+ this.revisionFeedDocCache.set(docId, state);
3950
+ }
3951
+ return state;
3952
+ }
3953
+ /** Insert/update the revision-feed LRU, evicting the least-recently-used. */
3954
+ putRevisionFeedCache(docId, state) {
3955
+ this.revisionFeedDocCache.delete(docId);
3956
+ this.revisionFeedDocCache.set(docId, state);
3957
+ while (this.revisionFeedDocCache.size > this.revisionFeedCacheLimit) {
3958
+ const oldest = this.revisionFeedDocCache.keys().next().value;
3959
+ if (oldest === undefined) {
3960
+ break;
3961
+ }
3962
+ this.revisionFeedDocCache.delete(oldest);
3963
+ }
3964
+ }
2292
3965
  async getDocumentHistoryPage(docId, options) {
2293
3966
  const startedAt = Date.now();
2294
3967
  const limit = Math.max(1, Math.floor(options?.limit ?? 100));
@@ -2375,7 +4048,7 @@ class BaseMindooDB {
2375
4048
  result.set(metadata.id, null);
2376
4049
  continue;
2377
4050
  }
2378
- const isValid = await this.tenant.verifySignature(entry.encryptedData, entry.signature, entry.createdByPublicKey);
4051
+ const isValid = await this.tenant.verifyEntrySignature(entry, entry.encryptedData);
2379
4052
  if (!isValid) {
2380
4053
  result.set(metadata.id, null);
2381
4054
  continue;
@@ -2498,7 +4171,7 @@ class BaseMindooDB {
2498
4171
  this.logger.warn(`Admin-only DB: skipping DAG details for ${entry.id} not signed by admin key`);
2499
4172
  }
2500
4173
  else {
2501
- isValid = await this.tenant.verifySignature(entry.encryptedData, entry.signature, entry.createdByPublicKey);
4174
+ isValid = await this.tenant.verifyEntrySignature(entry, entry.encryptedData);
2502
4175
  }
2503
4176
  if (isValid) {
2504
4177
  const decryptedPayload = await this.tenant.decryptPayload(entry.encryptedData, entry.decryptionKeyId);
@@ -3195,7 +4868,7 @@ class BaseMindooDB {
3195
4868
  this.logger.warn(`Admin-only DB: skipping conflict analysis entry ${entry.id} not signed by admin key`);
3196
4869
  continue;
3197
4870
  }
3198
- const isValid = await this.tenant.verifySignature(entry.encryptedData, entry.signature, entry.createdByPublicKey);
4871
+ const isValid = await this.tenant.verifyEntrySignature(entry, entry.encryptedData);
3199
4872
  if (!isValid) {
3200
4873
  this.logger.warn(`Invalid signature for conflict analysis entry ${entry.id}, skipping`);
3201
4874
  continue;
@@ -3645,16 +5318,76 @@ class BaseMindooDB {
3645
5318
  docIds.push(docId);
3646
5319
  }
3647
5320
  }
3648
- return docIds;
3649
- }
3650
- async deleteDocument(docId, options = {}) {
3651
- return this.deleteDocInternal(docId, options.signingKeyPair, options.signingKeyPassword);
5321
+ return docIds;
5322
+ }
5323
+ async deleteDocument(docId, options = {}) {
5324
+ return this.deleteDocInternal(docId, options.signingKeyPair, options.signingKeyPassword, options.bypassAccessControlPrecheck);
5325
+ }
5326
+ /**
5327
+ * Bulk-delete documents in a single append-only store write.
5328
+ *
5329
+ * Semantically equivalent to calling {@link deleteDocument} once per id, but
5330
+ * all `doc_delete` lifecycle entries are written through ONE `putEntries()`
5331
+ * call (a single readwrite transaction on IndexedDB-backed stores), and the
5332
+ * deleted documents are dropped from the L1 cache instead of being
5333
+ * dirty-tracked (the entries are already durable, so no L2 flush is needed).
5334
+ *
5335
+ * Documents that are missing or already deleted are skipped (idempotent
5336
+ * bulk semantics: a re-run of a destructive import must not fail because a
5337
+ * previous run already removed some documents). Any other failure, including
5338
+ * a denied ACL precheck, rejects the whole call before anything is written.
5339
+ */
5340
+ async deleteDocuments(docIds, options = {}) {
5341
+ this.assertWritable("deleteDocuments");
5342
+ if (docIds.length === 0) {
5343
+ return;
5344
+ }
5345
+ await this.assertLifecycleMutationAllowed(options.signingKeyPair, options.signingKeyPassword);
5346
+ this.logger.info(`Bulk-deleting ${docIds.length} documents`);
5347
+ // Unlike creation, a delete must first materialize the CURRENT document
5348
+ // (the doc_delete entry is an Automerge change on its heads), which costs
5349
+ // an IndexedDB metadata scan plus per-entry decrypt + signature verify.
5350
+ // Those are independent per document and dominated by IndexedDB/WebCrypto
5351
+ // latency (both run off the JS thread), so prepare with bounded
5352
+ // concurrency instead of strictly sequentially. Results keep input order;
5353
+ // any failure rejects the whole batch before anything is written
5354
+ // (fail-fast, same as the sequential version).
5355
+ const preparedByIndex = new Array(docIds.length).fill(null);
5356
+ const concurrency = Math.min(8, docIds.length);
5357
+ let nextIndex = 0;
5358
+ await Promise.all(Array.from({ length: concurrency }, async () => {
5359
+ while (true) {
5360
+ const index = nextIndex++;
5361
+ if (index >= docIds.length) {
5362
+ return;
5363
+ }
5364
+ const docId = docIds[index];
5365
+ const internalDoc = await this.loadDocumentInternal(docId);
5366
+ if (!internalDoc || internalDoc.isDeleted) {
5367
+ this.logger.debug(`Skipping bulk delete for ${docId} — not found or already deleted`);
5368
+ continue;
5369
+ }
5370
+ preparedByIndex[index] = await this.prepareLifecycleEntry(internalDoc, "doc_delete", options.signingKeyPair, options.signingKeyPassword, options.bypassAccessControlPrecheck);
5371
+ }
5372
+ }));
5373
+ const prepared = preparedByIndex.filter((p) => p !== null);
5374
+ if (prepared.length === 0) {
5375
+ return;
5376
+ }
5377
+ await this.store.putEntries(prepared.map((p) => p.fullEntry));
5378
+ for (const p of prepared) {
5379
+ await this.applyLifecycleEntryLocally(p, { markDirty: false });
5380
+ }
5381
+ // One metadata-checkpoint invalidation for the whole batch.
5382
+ this.cacheMetaDirty = true;
5383
+ this.cacheManager?.markDirty();
5384
+ this.logger.info(`Bulk-deleted ${prepared.length} documents (${docIds.length - prepared.length} skipped)`);
3652
5385
  }
3653
5386
  async deleteDocumentWithSigningKey(docId, signingKeyPair, signingKeyPassword) {
3654
5387
  return this.deleteDocument(docId, { signingKeyPair, signingKeyPassword });
3655
5388
  }
3656
5389
  async undeleteDocument(docId, options = {}) {
3657
- return this.undeleteDocInternal(docId, options.signingKeyPair, options.signingKeyPassword);
5390
+ return this.undeleteDocInternal(docId, options.signingKeyPair, options.signingKeyPassword, options.bypassAccessControlPrecheck);
3658
5391
  }
3659
5392
  async assertLifecycleMutationAllowed(signingKeyPair, signingKeyPassword) {
3660
5393
  this.assertWritable("document lifecycle mutation");
@@ -3679,7 +5412,18 @@ class BaseMindooDB {
3679
5412
  * change that advances `_lastModified`, wraps it in the requested lifecycle
3680
5413
  * StoreEntry type, signs/encrypts it, and updates local caches/indexes.
3681
5414
  */
3682
- async writeLifecycleEntry(internalDoc, entryType, signingKeyPair, signingKeyPassword) {
5415
+ async writeLifecycleEntry(internalDoc, entryType, signingKeyPair, signingKeyPassword, bypassPrecheck) {
5416
+ const prepared = await this.prepareLifecycleEntry(internalDoc, entryType, signingKeyPair, signingKeyPassword, bypassPrecheck);
5417
+ await this.store.putEntries([prepared.fullEntry]);
5418
+ await this.applyLifecycleEntryLocally(prepared, { markDirty: true });
5419
+ }
5420
+ /**
5421
+ * Build (but do not persist) a delete/undelete lifecycle entry for an
5422
+ * existing document, including the ACL precheck. Callers persist the
5423
+ * returned `fullEntry` (single or batched `putEntries`) and then call
5424
+ * {@link applyLifecycleEntryLocally} to update caches and indexes.
5425
+ */
5426
+ async prepareLifecycleEntry(internalDoc, entryType, signingKeyPair, signingKeyPassword, bypassPrecheck) {
3683
5427
  // Delete and undelete are encoded as normal Automerge changes so the DAG
3684
5428
  // keeps causal ancestry, while the StoreEntry type carries lifecycle intent.
3685
5429
  const useCustomSigningKey = signingKeyPair !== undefined && signingKeyPassword !== undefined;
@@ -3688,12 +5432,12 @@ class BaseMindooDB {
3688
5432
  // Lifecycle changes are intentionally non-destructive: they only bump
3689
5433
  // `_lastModified` and repair missing legacy attachment arrays. The document
3690
5434
  // body remains available for history and future undeletion.
3691
- const newDoc = automerge_adapter_1.Automerge.change(internalDoc.doc, { time: now }, (doc) => {
3692
- if (!Array.isArray(doc._attachments)) {
3693
- doc._attachments = [];
5435
+ const newDoc = this.runChangeWithOutdatedDocRecovery(internalDoc, (doc) => automerge_adapter_1.Automerge.change(doc, { time: now }, (mutableDoc) => {
5436
+ if (!Array.isArray(mutableDoc._attachments)) {
5437
+ mutableDoc._attachments = [];
3694
5438
  }
3695
- doc._lastModified = now;
3696
- });
5439
+ mutableDoc._lastModified = now;
5440
+ }));
3697
5441
  const changeBytes = automerge_adapter_1.Automerge.getLastLocalChange(newDoc);
3698
5442
  if (!changeBytes) {
3699
5443
  throw new Error("Failed to get change bytes from Automerge document");
@@ -3718,6 +5462,10 @@ class BaseMindooDB {
3718
5462
  signature = await this.tenant.signPayload(encryptedPayload);
3719
5463
  createdByPublicKey = currentUser.userSigningPublicKey;
3720
5464
  }
5465
+ // Attachment snapshot for lifecycle entries: a delete marks the document
5466
+ // inactive, so its attachments are no longer referenced (empty set -> their
5467
+ // chunks become reclaim candidates); an undelete restores the current set.
5468
+ const lifecycleAttachmentRefs = entryType === "doc_delete" ? [] : this.collectAttachmentRefs(newDoc);
3721
5469
  // The encrypted Automerge change is the payload; the metadata classifies it
3722
5470
  // as a delete or undelete lifecycle entry for fast history/view queries.
3723
5471
  const entryMetadata = {
@@ -3732,28 +5480,85 @@ class BaseMindooDB {
3732
5480
  signature,
3733
5481
  originalSize: changeBytes.length,
3734
5482
  encryptedSize: encryptedPayload.length,
5483
+ attachmentRefs: lifecycleAttachmentRefs.length > 0 ? lifecycleAttachmentRefs : undefined,
5484
+ entryVersion: types_1.CURRENT_STORE_ENTRY_VERSION,
3735
5485
  };
5486
+ // Bind the metadata with the author signature (audit finding #5).
5487
+ entryMetadata.metadataSignature = await this.computeEntryMetadataSignature(entryMetadata, useCustomSigningKey ? { signingKeyPair, signingKeyPassword } : undefined);
3736
5488
  const fullEntry = {
3737
5489
  ...entryMetadata,
3738
5490
  encryptedData: encryptedPayload,
3739
5491
  };
3740
- await this.store.putEntries([fullEntry]);
5492
+ // Client-side write-policy precheck (docs/accesscontrol.md §9). `internalDoc.doc`
5493
+ // is still the pre-change state here (reassigned in
5494
+ // applyLifecycleEntryLocally, after the store write). For deletes the rules
5495
+ // default to the "before" state; both states are supplied so either `when`
5496
+ // resolves. Throws AccessDeniedError if denied.
5497
+ {
5498
+ const creatorKey = await this.resolveCreatorSigningKey(docId);
5499
+ const beforeState = internalDoc.doc;
5500
+ await this.assertWriteAllowed({
5501
+ op: entryType,
5502
+ signerKey: createdByPublicKey,
5503
+ isAuthor: creatorKey !== null && createdByPublicKey === creatorKey,
5504
+ bypass: bypassPrecheck,
5505
+ getBeforeDoc: () => this.convertAutomergeToJS(beforeState),
5506
+ getAfterDoc: () => this.convertAutomergeToJS(newDoc),
5507
+ });
5508
+ }
5509
+ return {
5510
+ internalDoc,
5511
+ entryType,
5512
+ newDoc,
5513
+ automergeHash,
5514
+ entryId,
5515
+ entryMetadata,
5516
+ fullEntry,
5517
+ now,
5518
+ };
5519
+ }
5520
+ /**
5521
+ * Apply a persisted lifecycle entry (see {@link prepareLifecycleEntry}) to
5522
+ * the in-memory document, cache, and changefeed index. Must be called after
5523
+ * the entry was written to the store.
5524
+ *
5525
+ * With `markDirty: false` (bulk path) the document is dropped from the L1
5526
+ * cache instead of being dirty-tracked: the lifecycle entry is already
5527
+ * durable in the append-only store, so skipping the L2 flush avoids the
5528
+ * per-document flush-before-evict write storm during large batches.
5529
+ */
5530
+ async applyLifecycleEntryLocally(prepared, options) {
5531
+ const { internalDoc, entryType, newDoc, automergeHash, entryId, entryMetadata, now } = prepared;
5532
+ const docId = internalDoc.id;
3741
5533
  this.registerAutomergeHashMapping(docId, automergeHash, entryId);
3742
5534
  // Keep the loaded document, cache, index, and sync dirty tracking in step
3743
5535
  // with the append-only store entry we just wrote.
3744
5536
  internalDoc.doc = newDoc;
3745
5537
  internalDoc.isDeleted = entryType === "doc_delete";
3746
5538
  internalDoc.lastModified = now;
3747
- await this.storeCachedDocument(internalDoc);
3748
- this.updateIndex(docId, internalDoc.lastModified, internalDoc.isDeleted, internalDoc.decryptionKeyId, "visible");
3749
- this.markDocDirty(docId);
5539
+ // The delete/undelete entry just written is versioned and un-witnessed, so
5540
+ // the document is awaiting witness until pushed + stamped with a receipt.
5541
+ internalDoc.awaitingWitness = (0, storeEntryTime_1.isProvisional)(entryMetadata) || (internalDoc.awaitingWitness ?? false);
5542
+ // The doc is now witness-era (we just wrote a versioned entry), so it is
5543
+ // witnessed iff it is no longer awaiting a receipt.
5544
+ internalDoc.witnessed = (0, storeEntryTime_1.isVersioned)(entryMetadata) && !internalDoc.awaitingWitness;
5545
+ if (options.markDirty) {
5546
+ await this.storeCachedDocument(internalDoc);
5547
+ this.updateIndex(docId, internalDoc.lastModified, internalDoc.isDeleted, internalDoc.decryptionKeyId, "visible", internalDoc.awaitingWitness, internalDoc.witnessed);
5548
+ this.markDocDirty(docId);
5549
+ }
5550
+ else {
5551
+ this.docCache.delete(docId);
5552
+ this.dirtyDocIds.delete(docId);
5553
+ this.updateIndex(docId, internalDoc.lastModified, internalDoc.isDeleted, internalDoc.decryptionKeyId, "visible", internalDoc.awaitingWitness, internalDoc.witnessed);
5554
+ }
3750
5555
  }
3751
5556
  /**
3752
5557
  * Internal method to delete a document.
3753
5558
  * Handles both regular deletion (signed by current user) and
3754
5559
  * deletion with a custom signing key (e.g., for directory operations).
3755
5560
  */
3756
- async deleteDocInternal(docId, signingKeyPair, signingKeyPassword) {
5561
+ async deleteDocInternal(docId, signingKeyPair, signingKeyPassword, bypassPrecheck) {
3757
5562
  const useCustomSigningKey = signingKeyPair !== undefined && signingKeyPassword !== undefined;
3758
5563
  this.logger.debug(`Deleting document ${docId}${useCustomSigningKey ? ' using custom signing key' : ''}`);
3759
5564
  await this.assertLifecycleMutationAllowed(signingKeyPair, signingKeyPassword);
@@ -3762,26 +5567,26 @@ class BaseMindooDB {
3762
5567
  if (!internalDoc || internalDoc.isDeleted) {
3763
5568
  throw new Error(`Document ${docId} not found or already deleted`);
3764
5569
  }
3765
- await this.writeLifecycleEntry(internalDoc, "doc_delete", signingKeyPair, signingKeyPassword);
5570
+ await this.writeLifecycleEntry(internalDoc, "doc_delete", signingKeyPair, signingKeyPassword, bypassPrecheck);
3766
5571
  this.logger.info(`Document ${docId} deleted successfully`);
3767
5572
  }
3768
- async undeleteDocInternal(docId, signingKeyPair, signingKeyPassword) {
5573
+ async undeleteDocInternal(docId, signingKeyPair, signingKeyPassword, bypassPrecheck) {
3769
5574
  const useCustomSigningKey = signingKeyPair !== undefined && signingKeyPassword !== undefined;
3770
5575
  this.logger.debug(`Undeleting document ${docId}${useCustomSigningKey ? ' using custom signing key' : ''}`);
3771
5576
  await this.assertLifecycleMutationAllowed(signingKeyPair, signingKeyPassword);
3772
5577
  const internalDoc = await this.loadDocumentInternal(docId);
3773
5578
  if (!internalDoc) {
3774
- throw new Error(`Document ${docId} not found`);
5579
+ throw new errors_1.DocumentNotFoundError(docId);
3775
5580
  }
3776
5581
  if (!internalDoc.isDeleted) {
3777
5582
  this.logger.debug(`Document ${docId} is already alive; undelete is a no-op`);
3778
5583
  return;
3779
5584
  }
3780
- await this.writeLifecycleEntry(internalDoc, "doc_undelete", signingKeyPair, signingKeyPassword);
5585
+ await this.writeLifecycleEntry(internalDoc, "doc_undelete", signingKeyPair, signingKeyPassword, bypassPrecheck);
3781
5586
  this.logger.info(`Document ${docId} undeleted successfully`);
3782
5587
  }
3783
5588
  async changeDoc(doc, changeFunc, options = {}) {
3784
- return this.changeDocInternal(doc, changeFunc, options.signingKeyPair, options.signingKeyPassword);
5589
+ return this.changeDocInternal(doc, changeFunc, options.signingKeyPair, options.signingKeyPassword, options.bypassAccessControlPrecheck);
3785
5590
  }
3786
5591
  async changeDocWithSigningKey(doc, changeFunc, signingKeyPair, signingKeyPassword) {
3787
5592
  return this.changeDoc(doc, changeFunc, { signingKeyPair, signingKeyPassword });
@@ -3801,12 +5606,12 @@ class BaseMindooDB {
3801
5606
  if (!internalDoc) {
3802
5607
  const loadedDoc = await this.loadDocumentInternal(docId);
3803
5608
  if (!loadedDoc) {
3804
- throw new Error(`Document ${docId} not found`);
5609
+ throw new errors_1.DocumentNotFoundError(docId);
3805
5610
  }
3806
5611
  internalDoc = loadedDoc;
3807
5612
  }
3808
5613
  if (internalDoc.isDeleted) {
3809
- throw new Error(`Document ${docId} has been deleted`);
5614
+ throw new errors_1.DocumentDeletedError(docId);
3810
5615
  }
3811
5616
  this.validateTextPatch(patch);
3812
5617
  const now = Date.now();
@@ -3821,14 +5626,15 @@ class BaseMindooDB {
3821
5626
  };
3822
5627
  let newDoc;
3823
5628
  try {
3824
- if (baseHeads && baseHeads.length > 0) {
3825
- const result = automerge_adapter_1.Automerge.changeAt(internalDoc.doc, baseHeads, applyEdits);
3826
- newDoc = result.newDoc;
3827
- }
3828
- else {
3829
- newDoc = automerge_adapter_1.Automerge.change(internalDoc.doc, applyEdits);
3830
- }
3831
- this.logger.debug(`Successfully applied text patch, new document heads: ${JSON.stringify(automerge_adapter_1.Automerge.getHeads(newDoc))}`);
5629
+ newDoc = this.runChangeWithOutdatedDocRecovery(internalDoc, (doc) => {
5630
+ if (baseHeads && baseHeads.length > 0) {
5631
+ const result = automerge_adapter_1.Automerge.changeAt(doc, baseHeads, applyEdits);
5632
+ return result.newDoc;
5633
+ }
5634
+ return automerge_adapter_1.Automerge.change(doc, applyEdits);
5635
+ });
5636
+ if (this.isDebugEnabled())
5637
+ this.logger.debug(`Successfully applied text patch, new document heads: ${JSON.stringify(automerge_adapter_1.Automerge.getHeads(newDoc))}`);
3832
5638
  }
3833
5639
  catch (error) {
3834
5640
  this.logger.error(`Error applying text patch for document ${docId}:`, error);
@@ -3864,31 +5670,46 @@ class BaseMindooDB {
3864
5670
  if (!internalDoc) {
3865
5671
  const loadedDoc = await this.loadDocumentInternal(docId);
3866
5672
  if (!loadedDoc) {
3867
- throw new Error(`Document ${docId} not found`);
5673
+ throw new errors_1.DocumentNotFoundError(docId);
3868
5674
  }
3869
5675
  internalDoc = loadedDoc;
3870
5676
  }
3871
5677
  if (internalDoc.isDeleted) {
3872
- throw new Error(`Document ${docId} has been deleted`);
5678
+ throw new errors_1.DocumentDeletedError(docId);
3873
5679
  }
3874
5680
  this.validateRichTextPatch(patch);
3875
5681
  const now = Date.now();
3876
5682
  const headsBeforeChange = automerge_adapter_1.Automerge.getHeads(internalDoc.doc);
5683
+ const spansSequence = patch.spansSequence ?? (patch.spans ? [patch.spans] : []);
3877
5684
  const applySpans = (automergeDoc) => {
3878
5685
  this.ensureRichTextPath(automergeDoc, patch.path);
3879
- automerge_adapter_1.Automerge.updateSpans(automergeDoc, patch.path, this.reviveRichTextSpans(patch.spans), patch.updateSpansConfig);
5686
+ for (const spans of spansSequence) {
5687
+ const revivedSpans = this.reviveRichTextSpans(spans);
5688
+ try {
5689
+ automerge_adapter_1.Automerge.updateSpans(automergeDoc, patch.path, revivedSpans, patch.updateSpansConfig);
5690
+ }
5691
+ catch (error) {
5692
+ if (!this.isRichTextUpdateSpansBoundsError(error)) {
5693
+ throw error;
5694
+ }
5695
+ this.logger.warn(`updateSpans failed for document ${docId} at '${patch.path.map(String).join(".")}', resetting rich-text field and applying snapshot from scratch`, error);
5696
+ this.setJsonValueAtPath(automergeDoc, patch.path, "");
5697
+ automerge_adapter_1.Automerge.updateSpans(automergeDoc, patch.path, revivedSpans, patch.updateSpansConfig);
5698
+ }
5699
+ }
3880
5700
  automergeDoc._lastModified = now;
3881
5701
  };
3882
5702
  let newDoc;
3883
5703
  try {
3884
- if (patch.baseHeads && patch.baseHeads.length > 0) {
3885
- const result = automerge_adapter_1.Automerge.changeAt(internalDoc.doc, patch.baseHeads, applySpans);
3886
- newDoc = result.newDoc;
3887
- }
3888
- else {
3889
- newDoc = automerge_adapter_1.Automerge.change(internalDoc.doc, applySpans);
3890
- }
3891
- this.logger.debug(`Successfully applied rich-text patch, new document heads: ${JSON.stringify(automerge_adapter_1.Automerge.getHeads(newDoc))}`);
5704
+ newDoc = this.runChangeWithOutdatedDocRecovery(internalDoc, (doc) => {
5705
+ if (patch.baseHeads && patch.baseHeads.length > 0) {
5706
+ const result = automerge_adapter_1.Automerge.changeAt(doc, patch.baseHeads, applySpans);
5707
+ return result.newDoc;
5708
+ }
5709
+ return automerge_adapter_1.Automerge.change(doc, applySpans);
5710
+ });
5711
+ if (this.isDebugEnabled())
5712
+ this.logger.debug(`Successfully applied rich-text patch, new document heads: ${JSON.stringify(automerge_adapter_1.Automerge.getHeads(newDoc))}`);
3892
5713
  }
3893
5714
  catch (error) {
3894
5715
  this.logger.error(`Error applying rich-text patch for document ${docId}:`, error);
@@ -3909,6 +5730,93 @@ class BaseMindooDB {
3909
5730
  data: wrapped.getData(),
3910
5731
  };
3911
5732
  }
5733
+ async applyRichTextStepsPatch(doc, patch) {
5734
+ this.assertWritable("applyRichTextStepsPatch");
5735
+ const docId = doc.getId();
5736
+ this.logger.debug(`===== applyRichTextStepsPatch called for document ${docId} =====`);
5737
+ if (this._isAdminOnlyDb) {
5738
+ const adminPublicKey = this.getAdminPublicKey();
5739
+ const currentUser = await this.tenant.getCurrentUserId();
5740
+ if (currentUser.userSigningPublicKey !== adminPublicKey) {
5741
+ throw new Error("Admin-only database: only the admin key can modify data");
5742
+ }
5743
+ }
5744
+ let internalDoc = this.getCachedDocument(docId);
5745
+ if (!internalDoc) {
5746
+ const loadedDoc = await this.loadDocumentInternal(docId);
5747
+ if (!loadedDoc) {
5748
+ throw new errors_1.DocumentNotFoundError(docId);
5749
+ }
5750
+ internalDoc = loadedDoc;
5751
+ }
5752
+ if (internalDoc.isDeleted) {
5753
+ throw new errors_1.DocumentDeletedError(docId);
5754
+ }
5755
+ this.validateRichTextStepPatch(patch);
5756
+ const now = Date.now();
5757
+ const headsBeforeChange = automerge_adapter_1.Automerge.getHeads(internalDoc.doc);
5758
+ const applySteps = (automergeDoc) => {
5759
+ this.ensureRichTextPath(automergeDoc, patch.path);
5760
+ const beforeValue = this.readValueAtPath(automergeDoc, patch.path);
5761
+ const beforeLength = typeof beforeValue === "string" ? beforeValue.length : null;
5762
+ this.logger.info("[RichTextSteps] Applying positional steps", {
5763
+ docId,
5764
+ path: patch.path,
5765
+ baseHeads: patch.baseHeads,
5766
+ stepCount: patch.steps.length,
5767
+ firstStep: patch.steps[0],
5768
+ beforeLength,
5769
+ });
5770
+ for (const step of patch.steps) {
5771
+ automerge_adapter_1.Automerge.splice(automergeDoc, patch.path, step.index, step.deleteCount, step.insert ?? "");
5772
+ for (const markRange of step.marks ?? []) {
5773
+ for (const [name, value] of Object.entries(markRange.marks)) {
5774
+ automerge_adapter_1.Automerge.mark(automergeDoc, patch.path, {
5775
+ start: markRange.index,
5776
+ end: markRange.index + markRange.length,
5777
+ expand: "none",
5778
+ }, name, this.reviveRichTextValue(value));
5779
+ }
5780
+ }
5781
+ }
5782
+ const afterValue = this.readValueAtPath(automergeDoc, patch.path);
5783
+ this.logger.info("[RichTextSteps] Applied positional steps", {
5784
+ docId,
5785
+ afterLength: typeof afterValue === "string" ? afterValue.length : null,
5786
+ });
5787
+ automergeDoc._lastModified = now;
5788
+ };
5789
+ let newDoc;
5790
+ try {
5791
+ newDoc = this.runChangeWithOutdatedDocRecovery(internalDoc, (doc) => {
5792
+ if (patch.baseHeads && patch.baseHeads.length > 0) {
5793
+ const result = automerge_adapter_1.Automerge.changeAt(doc, patch.baseHeads, applySteps);
5794
+ return result.newDoc;
5795
+ }
5796
+ return automerge_adapter_1.Automerge.change(doc, applySteps);
5797
+ });
5798
+ if (this.isDebugEnabled())
5799
+ this.logger.debug(`Successfully applied rich-text steps, new document heads: ${JSON.stringify(automerge_adapter_1.Automerge.getHeads(newDoc))}`);
5800
+ }
5801
+ catch (error) {
5802
+ this.logger.error(`Error applying rich-text steps for document ${docId}:`, error);
5803
+ throw error;
5804
+ }
5805
+ await this.persistDocumentChange({
5806
+ internalDoc,
5807
+ newDoc,
5808
+ now,
5809
+ headsBeforeChange,
5810
+ useCustomKey: false,
5811
+ successMessage: "rich text steps patched",
5812
+ });
5813
+ const wrapped = this.wrapDocument(internalDoc);
5814
+ return {
5815
+ doc: wrapped,
5816
+ heads: wrapped.getHeads(),
5817
+ data: wrapped.getData(),
5818
+ };
5819
+ }
3912
5820
  async getRichTextSnapshot(doc, path) {
3913
5821
  const docId = doc.getId();
3914
5822
  this.validateJsonPath(path, "Rich-text snapshot");
@@ -3919,12 +5827,12 @@ class BaseMindooDB {
3919
5827
  if (!internalDoc) {
3920
5828
  const loadedDoc = await this.loadDocumentInternal(docId);
3921
5829
  if (!loadedDoc) {
3922
- throw new Error(`Document ${docId} not found`);
5830
+ throw new errors_1.DocumentNotFoundError(docId);
3923
5831
  }
3924
5832
  internalDoc = loadedDoc;
3925
5833
  }
3926
5834
  if (internalDoc.isDeleted) {
3927
- throw new Error(`Document ${docId} has been deleted`);
5835
+ throw new errors_1.DocumentDeletedError(docId);
3928
5836
  }
3929
5837
  const spans = automerge_adapter_1.Automerge.spans(internalDoc.doc, path);
3930
5838
  return {
@@ -3933,6 +5841,124 @@ class BaseMindooDB {
3933
5841
  spans: this.dehydrateRichTextSpans(spans),
3934
5842
  };
3935
5843
  }
5844
+ async exportAutomergeSnapshot(doc) {
5845
+ const internalDoc = await this.resolveReadableInternalDoc(doc);
5846
+ const binary = automerge_adapter_1.Automerge.save(internalDoc.doc);
5847
+ return {
5848
+ binary: new Uint8Array(binary),
5849
+ heads: automerge_adapter_1.Automerge.getHeads(internalDoc.doc),
5850
+ };
5851
+ }
5852
+ async applyAutomergeChanges(doc, patch) {
5853
+ this.assertWritable("applyAutomergeChanges");
5854
+ const docId = doc.getId();
5855
+ this.logger.debug(`===== applyAutomergeChanges called for document ${docId} =====`);
5856
+ if (this._isAdminOnlyDb) {
5857
+ const adminPublicKey = this.getAdminPublicKey();
5858
+ const currentUser = await this.tenant.getCurrentUserId();
5859
+ if (currentUser.userSigningPublicKey !== adminPublicKey) {
5860
+ throw new Error("Admin-only database: only the admin key can modify data");
5861
+ }
5862
+ }
5863
+ if (!Array.isArray(patch.changes) || patch.changes.length === 0) {
5864
+ throw new Error("Automerge changes patch must include at least one change byte sequence");
5865
+ }
5866
+ let internalDoc = this.getCachedDocument(docId);
5867
+ if (!internalDoc) {
5868
+ const loadedDoc = await this.loadDocumentInternal(docId);
5869
+ if (!loadedDoc) {
5870
+ throw new errors_1.DocumentNotFoundError(docId);
5871
+ }
5872
+ internalDoc = loadedDoc;
5873
+ }
5874
+ if (internalDoc.isDeleted) {
5875
+ throw new errors_1.DocumentDeletedError(docId);
5876
+ }
5877
+ await this.hydrateAutomergeHashMappingsFromStore(docId);
5878
+ const now = Date.now();
5879
+ const headsBeforeChange = automerge_adapter_1.Automerge.getHeads(internalDoc.doc);
5880
+ if (patch.baseHeads?.length) {
5881
+ const baseHeadsKey = [...patch.baseHeads].sort().join("|");
5882
+ const currentHeadsKey = [...headsBeforeChange].sort().join("|");
5883
+ if (baseHeadsKey !== currentHeadsKey) {
5884
+ this.logger.debug(`Applying Automerge changes for document ${docId} with stale baseHeads; merging into current heads`);
5885
+ }
5886
+ }
5887
+ let newDoc;
5888
+ try {
5889
+ newDoc = this.runChangeWithOutdatedDocRecovery(internalDoc, (currentDoc) => {
5890
+ const [mergedDoc] = automerge_adapter_1.Automerge.applyChanges(currentDoc, patch.changes.map((change) => new Uint8Array(change)));
5891
+ return mergedDoc;
5892
+ });
5893
+ if (this.isDebugEnabled()) {
5894
+ this.logger.debug(`Successfully applied Automerge changes, new document heads: ${JSON.stringify(automerge_adapter_1.Automerge.getHeads(newDoc))}`);
5895
+ }
5896
+ }
5897
+ catch (error) {
5898
+ this.logger.error(`Error applying Automerge changes for document ${docId}:`, error);
5899
+ throw error;
5900
+ }
5901
+ const incomingChanges = patch.changes.map((change) => new Uint8Array(change));
5902
+ const changesToPersist = incomingChanges.filter((changeBytes) => {
5903
+ const hash = automerge_adapter_1.Automerge.decodeChange(changeBytes).hash;
5904
+ return this.getEntryIdForAutomergeHash(docId, hash) === null;
5905
+ });
5906
+ if (changesToPersist.length === 0) {
5907
+ internalDoc.doc = newDoc;
5908
+ internalDoc.lastModified = now;
5909
+ await this.storeCachedDocument(internalDoc);
5910
+ this.markDocDirty(docId);
5911
+ }
5912
+ else {
5913
+ await this.persistAutomergeDocumentChanges({
5914
+ internalDoc,
5915
+ newDoc,
5916
+ now,
5917
+ changeBytesList: changesToPersist,
5918
+ useCustomKey: false,
5919
+ successMessage: "Automerge changes applied",
5920
+ });
5921
+ }
5922
+ const wrapped = this.wrapDocument(internalDoc);
5923
+ const mergedHeads = wrapped.getHeads();
5924
+ let changesSince;
5925
+ if (patch.replicaHeads?.length) {
5926
+ try {
5927
+ const missingChanges = automerge_adapter_1.Automerge.getChangesSince(newDoc, patch.replicaHeads);
5928
+ changesSince = {
5929
+ sinceHeads: [...patch.replicaHeads],
5930
+ changes: missingChanges.map((change) => new Uint8Array(change)),
5931
+ };
5932
+ }
5933
+ catch (error) {
5934
+ this.logger.debug(`Could not compute Automerge changesSince for document ${docId}:`, error);
5935
+ }
5936
+ }
5937
+ return {
5938
+ doc: wrapped,
5939
+ heads: mergedHeads,
5940
+ data: wrapped.getData(),
5941
+ changesSince,
5942
+ };
5943
+ }
5944
+ async resolveReadableInternalDoc(doc) {
5945
+ const docId = doc.getId();
5946
+ let internalDoc = this.wrappedInternalDocs.get(doc) ?? null;
5947
+ if (!internalDoc) {
5948
+ internalDoc = this.getCachedDocument(docId);
5949
+ }
5950
+ if (!internalDoc) {
5951
+ const loadedDoc = await this.loadDocumentInternal(docId);
5952
+ if (!loadedDoc) {
5953
+ throw new errors_1.DocumentNotFoundError(docId);
5954
+ }
5955
+ internalDoc = loadedDoc;
5956
+ }
5957
+ if (internalDoc.isDeleted) {
5958
+ throw new errors_1.DocumentDeletedError(docId);
5959
+ }
5960
+ return internalDoc;
5961
+ }
3936
5962
  async applyJsonPatch(doc, patch) {
3937
5963
  this.assertWritable("applyJsonPatch");
3938
5964
  const docId = doc.getId();
@@ -3948,12 +5974,12 @@ class BaseMindooDB {
3948
5974
  if (!internalDoc) {
3949
5975
  const loadedDoc = await this.loadDocumentInternal(docId);
3950
5976
  if (!loadedDoc) {
3951
- throw new Error(`Document ${docId} not found`);
5977
+ throw new errors_1.DocumentNotFoundError(docId);
3952
5978
  }
3953
5979
  internalDoc = loadedDoc;
3954
5980
  }
3955
5981
  if (internalDoc.isDeleted) {
3956
- throw new Error(`Document ${docId} has been deleted`);
5982
+ throw new errors_1.DocumentDeletedError(docId);
3957
5983
  }
3958
5984
  this.validateJsonPatch(patch);
3959
5985
  const now = Date.now();
@@ -3964,14 +5990,15 @@ class BaseMindooDB {
3964
5990
  };
3965
5991
  let newDoc;
3966
5992
  try {
3967
- if (patch.baseHeads && patch.baseHeads.length > 0) {
3968
- const result = automerge_adapter_1.Automerge.changeAt(internalDoc.doc, patch.baseHeads, applyPatch);
3969
- newDoc = result.newDoc;
3970
- }
3971
- else {
3972
- newDoc = automerge_adapter_1.Automerge.change(internalDoc.doc, applyPatch);
3973
- }
3974
- this.logger.debug(`Successfully applied JSON patch, new document heads: ${JSON.stringify(automerge_adapter_1.Automerge.getHeads(newDoc))}`);
5993
+ newDoc = this.runChangeWithOutdatedDocRecovery(internalDoc, (doc) => {
5994
+ if (patch.baseHeads && patch.baseHeads.length > 0) {
5995
+ const result = automerge_adapter_1.Automerge.changeAt(doc, patch.baseHeads, applyPatch);
5996
+ return result.newDoc;
5997
+ }
5998
+ return automerge_adapter_1.Automerge.change(doc, applyPatch);
5999
+ });
6000
+ if (this.isDebugEnabled())
6001
+ this.logger.debug(`Successfully applied JSON patch, new document heads: ${JSON.stringify(automerge_adapter_1.Automerge.getHeads(newDoc))}`);
3975
6002
  }
3976
6003
  catch (error) {
3977
6004
  this.logger.error(`Error applying JSON patch for document ${docId}:`, error);
@@ -3992,7 +6019,7 @@ class BaseMindooDB {
3992
6019
  data: wrapped.getData(),
3993
6020
  };
3994
6021
  }
3995
- async changeDocInternal(doc, changeFunc, signingKeyPair, signingKeyPassword) {
6022
+ async changeDocInternal(doc, changeFunc, signingKeyPair, signingKeyPassword, bypassPrecheck) {
3996
6023
  this.assertWritable("changeDoc");
3997
6024
  const docId = doc.getId();
3998
6025
  if ((signingKeyPair !== undefined) !== (signingKeyPassword !== undefined)) {
@@ -4016,7 +6043,7 @@ class BaseMindooDB {
4016
6043
  this.logger.debug(`Document ${docId} not in cache, loading from store`);
4017
6044
  const loadedDoc = await this.loadDocumentInternal(docId);
4018
6045
  if (!loadedDoc) {
4019
- throw new Error(`Document ${docId} not found`);
6046
+ throw new errors_1.DocumentNotFoundError(docId);
4020
6047
  }
4021
6048
  internalDoc = loadedDoc;
4022
6049
  this.logger.debug(`Successfully loaded document ${docId} from store for ${useCustomKey ? 'changeDocWithSigningKey' : 'changeDoc'}`);
@@ -4025,12 +6052,13 @@ class BaseMindooDB {
4025
6052
  this.logger.debug(`Document ${docId} found in cache`);
4026
6053
  }
4027
6054
  if (internalDoc.isDeleted) {
4028
- throw new Error(`Document ${docId} has been deleted`);
6055
+ throw new errors_1.DocumentDeletedError(docId);
4029
6056
  }
4030
6057
  // Apply the change function
4031
6058
  const now = Date.now();
4032
6059
  this.logger.debug(`Applying change function to document ${docId}`);
4033
- this.logger.debug(`Document state before change: heads=${JSON.stringify(automerge_adapter_1.Automerge.getHeads(internalDoc.doc))}`);
6060
+ if (this.isDebugEnabled())
6061
+ this.logger.debug(`Document state before change: heads=${JSON.stringify(automerge_adapter_1.Automerge.getHeads(internalDoc.doc))}`);
4034
6062
  // For async callbacks, we need to handle document modifications carefully.
4035
6063
  // Automerge.change() requires synchronous modifications, so we'll:
4036
6064
  // 1. Execute the async callback to do any async work (like signing)
@@ -4045,6 +6073,8 @@ class BaseMindooDB {
4045
6073
  const pendingAttachmentRemovals = new Set();
4046
6074
  // Map of attachmentId -> {lastChunkId, sizeIncrease} for appends
4047
6075
  const pendingAttachmentAppends = new Map();
6076
+ // Map of attachmentId -> extraction result for setAttachmentExtractedText
6077
+ const pendingAttachmentTextUpdates = new Map();
4048
6078
  // Reference to db for closures
4049
6079
  const db = this;
4050
6080
  // Guard flag to prevent operations after callback completes
@@ -4063,6 +6093,9 @@ class BaseMindooDB {
4063
6093
  getLastModified: () => internalDoc.lastModified,
4064
6094
  getDecryptionKeyId: () => internalDoc.decryptionKeyId,
4065
6095
  isDeleted: () => false,
6096
+ isAccessible: () => true,
6097
+ isAwaitingWitness: () => internalDoc.awaitingWitness ?? false,
6098
+ isWitnessed: () => internalDoc.witnessed ?? false,
4066
6099
  getHeads: () => automerge_adapter_1.Automerge.getHeads(internalDoc.doc),
4067
6100
  getData: () => {
4068
6101
  // Return a proxy that collects property assignments and deletions
@@ -4152,6 +6185,44 @@ class BaseMindooDB {
4152
6185
  pendingAttachmentRemovals.add(attachmentId);
4153
6186
  }
4154
6187
  },
6188
+ /**
6189
+ * Store the result of an external text extraction (e.g. OCR) at the
6190
+ * attachment entry in `_attachments`. See the full contract on
6191
+ * {@link MindooDoc.setAttachmentExtractedText} in types.ts.
6192
+ *
6193
+ * Behavior inside this changeDoc draft:
6194
+ * - Only records a pending update here; the `_attachments` array is
6195
+ * mutated once in the surrounding Automerge.change block when the
6196
+ * callback completes, alongside pending adds/removals/appends.
6197
+ * - The target attachment must exist in the current document or among
6198
+ * attachments added earlier in this same changeDoc call; attachments
6199
+ * already marked for removal in this call don't count.
6200
+ * - `text` is capped at ATTACHMENT_EXTRACTED_TEXT_MAX_CHARS before
6201
+ * being persisted. `text: null` clears a previous result including
6202
+ * its status/engine/extractedAt metadata.
6203
+ * - Calling this repeatedly for the same attachmentId within one
6204
+ * changeDoc keeps only the last result (map semantics).
6205
+ *
6206
+ * @param attachmentId ID of an attachment of this document.
6207
+ * @param result Extraction outcome; `status` defaults to "done" when
6208
+ * text is provided (applied in the change block).
6209
+ * @throws Error if the attachment does not exist (or was removed in
6210
+ * this changeDoc call), or when invoked after the callback finished.
6211
+ */
6212
+ setAttachmentExtractedText: (attachmentId, result) => {
6213
+ throwIfCallbackInactive('setAttachmentExtractedText');
6214
+ const payload = internalDoc.doc;
6215
+ const existingAttachments = payload._attachments || [];
6216
+ const existsInDoc = existingAttachments.some((a) => a.attachmentId === attachmentId && !pendingAttachmentRemovals.has(attachmentId));
6217
+ const existsInPending = pendingAttachmentAdditions.some((a) => a.attachmentId === attachmentId);
6218
+ if (!existsInDoc && !existsInPending) {
6219
+ throw new Error(`Attachment ${attachmentId} not found in document ${docId}`);
6220
+ }
6221
+ const cappedText = typeof result.text === "string" && result.text.length > types_1.ATTACHMENT_EXTRACTED_TEXT_MAX_CHARS
6222
+ ? result.text.slice(0, types_1.ATTACHMENT_EXTRACTED_TEXT_MAX_CHARS)
6223
+ : result.text;
6224
+ pendingAttachmentTextUpdates.set(attachmentId, { ...result, text: cappedText });
6225
+ },
4155
6226
  appendToAttachment: async (attachmentId, data) => {
4156
6227
  throwIfCallbackInactive('appendToAttachment');
4157
6228
  // Find the attachment reference
@@ -4215,7 +6286,7 @@ class BaseMindooDB {
4215
6286
  const headsBeforeChange = automerge_adapter_1.Automerge.getHeads(internalDoc.doc);
4216
6287
  let newDoc;
4217
6288
  try {
4218
- newDoc = automerge_adapter_1.Automerge.change(internalDoc.doc, (automergeDoc) => {
6289
+ newDoc = this.runChangeWithOutdatedDocRecovery(internalDoc, (doc) => automerge_adapter_1.Automerge.change(doc, (automergeDoc) => {
4219
6290
  // Apply all pending changes (sets/updates)
4220
6291
  for (const [key, value] of pendingChanges) {
4221
6292
  automergeDoc[key] = value;
@@ -4225,7 +6296,10 @@ class BaseMindooDB {
4225
6296
  delete automergeDoc[key];
4226
6297
  }
4227
6298
  // Apply pending attachment changes
4228
- if (pendingAttachmentAdditions.length > 0 || pendingAttachmentRemovals.size > 0 || pendingAttachmentAppends.size > 0) {
6299
+ if (pendingAttachmentAdditions.length > 0 ||
6300
+ pendingAttachmentRemovals.size > 0 ||
6301
+ pendingAttachmentAppends.size > 0 ||
6302
+ pendingAttachmentTextUpdates.size > 0) {
4229
6303
  // Initialize _attachments array if needed
4230
6304
  if (!automergeDoc._attachments) {
4231
6305
  automergeDoc._attachments = [];
@@ -4250,11 +6324,40 @@ class BaseMindooDB {
4250
6324
  for (const ref of pendingAttachmentAdditions) {
4251
6325
  attachments.push(ref);
4252
6326
  }
6327
+ // Apply extraction results (setAttachmentExtractedText)
6328
+ for (const [attachmentId, update] of pendingAttachmentTextUpdates) {
6329
+ const attachment = attachments.find(a => a.attachmentId === attachmentId);
6330
+ if (!attachment) {
6331
+ continue; // removed in the same changeDoc call
6332
+ }
6333
+ const isFullClear = update.text === null && update.status === undefined;
6334
+ if (isFullClear) {
6335
+ // `text: null` without a status resets the entry entirely
6336
+ // (e.g. before a re-extraction).
6337
+ delete attachment.extractedText;
6338
+ delete attachment.extractionStatus;
6339
+ delete attachment.extractionEngine;
6340
+ delete attachment.extractedAt;
6341
+ continue;
6342
+ }
6343
+ if (update.text === null) {
6344
+ delete attachment.extractedText;
6345
+ }
6346
+ else {
6347
+ attachment.extractedText = update.text;
6348
+ }
6349
+ attachment.extractionStatus = update.status ?? "done";
6350
+ if (update.engine !== undefined) {
6351
+ attachment.extractionEngine = update.engine;
6352
+ }
6353
+ attachment.extractedAt = update.extractedAt ?? now;
6354
+ }
4253
6355
  }
4254
6356
  // Update lastModified timestamp
4255
6357
  automergeDoc._lastModified = now;
4256
- });
4257
- this.logger.debug(`Successfully applied change function, new document heads: ${JSON.stringify(automerge_adapter_1.Automerge.getHeads(newDoc))}`);
6358
+ }));
6359
+ if (this.isDebugEnabled())
6360
+ this.logger.debug(`Successfully applied change function, new document heads: ${JSON.stringify(automerge_adapter_1.Automerge.getHeads(newDoc))}`);
4258
6361
  }
4259
6362
  catch (error) {
4260
6363
  this.logger.error(`Error in Automerge.change for document ${docId}:`, error);
@@ -4272,6 +6375,7 @@ class BaseMindooDB {
4272
6375
  signingKeyPassword,
4273
6376
  successMessage: useCustomKey ? "changed with custom signing key" : "changed",
4274
6377
  attachmentIds: pendingAttachmentAdditions.map((ref) => ref.attachmentId),
6378
+ bypassPrecheck,
4275
6379
  });
4276
6380
  }
4277
6381
  catch (error) {
@@ -4308,7 +6412,10 @@ class BaseMindooDB {
4308
6412
  const operationCount = (patch.set?.length ?? 0)
4309
6413
  + (patch.unset?.length ?? 0)
4310
6414
  + (patch.listDelete?.length ?? 0)
4311
- + (patch.listInsert?.length ?? 0);
6415
+ + (patch.listInsert?.length ?? 0)
6416
+ + (patch.textSplice?.length ?? 0)
6417
+ + (patch.textMark?.length ?? 0)
6418
+ + (patch.textUnmark?.length ?? 0);
4312
6419
  if (operationCount === 0) {
4313
6420
  throw new Error("JSON patch must include at least one operation");
4314
6421
  }
@@ -4336,13 +6443,69 @@ class BaseMindooDB {
4336
6443
  throw new Error("JSON listInsert values must be an array");
4337
6444
  }
4338
6445
  }
6446
+ for (const operation of patch.textSplice ?? []) {
6447
+ this.validateJsonPath(operation.path, "JSON textSplice");
6448
+ if (!Number.isInteger(operation.index) || operation.index < 0) {
6449
+ throw new Error("JSON textSplice index must be a non-negative integer");
6450
+ }
6451
+ if (!Number.isInteger(operation.deleteCount) || operation.deleteCount < 0) {
6452
+ throw new Error("JSON textSplice deleteCount must be a non-negative integer");
6453
+ }
6454
+ if (operation.insert !== undefined && typeof operation.insert !== "string") {
6455
+ throw new Error("JSON textSplice insert must be a string");
6456
+ }
6457
+ }
6458
+ for (const operation of patch.textMark ?? []) {
6459
+ this.validateJsonPath(operation.path, "JSON textMark");
6460
+ if (!Number.isInteger(operation.index) || operation.index < 0) {
6461
+ throw new Error("JSON textMark index must be a non-negative integer");
6462
+ }
6463
+ if (!Number.isInteger(operation.length) || operation.length <= 0) {
6464
+ throw new Error("JSON textMark length must be a positive integer");
6465
+ }
6466
+ if (!operation.marks || typeof operation.marks !== "object" || Array.isArray(operation.marks)) {
6467
+ throw new Error("JSON textMark marks must be an object");
6468
+ }
6469
+ }
6470
+ for (const operation of patch.textUnmark ?? []) {
6471
+ this.validateJsonPath(operation.path, "JSON textUnmark");
6472
+ if (!Number.isInteger(operation.index) || operation.index < 0) {
6473
+ throw new Error("JSON textUnmark index must be a non-negative integer");
6474
+ }
6475
+ if (!Number.isInteger(operation.length) || operation.length <= 0) {
6476
+ throw new Error("JSON textUnmark length must be a positive integer");
6477
+ }
6478
+ if (!Array.isArray(operation.names) || operation.names.some((name) => typeof name !== "string" || !name)) {
6479
+ throw new Error("JSON textUnmark names must be non-empty strings");
6480
+ }
6481
+ }
4339
6482
  }
4340
6483
  validateRichTextPatch(patch) {
4341
6484
  this.validateJsonPath(patch.path, "Rich-text patch");
4342
- if (!Array.isArray(patch.spans)) {
4343
- throw new Error("Rich-text patch spans must be an array");
6485
+ const hasSpans = patch.spans !== undefined;
6486
+ const hasSpansSequence = patch.spansSequence !== undefined;
6487
+ if (hasSpans === hasSpansSequence) {
6488
+ throw new Error("Rich-text patch must include exactly one of spans or spansSequence");
6489
+ }
6490
+ if (hasSpans) {
6491
+ if (!Array.isArray(patch.spans)) {
6492
+ throw new Error("Rich-text patch spans must be an array");
6493
+ }
6494
+ this.validateRichTextSpans(patch.spans);
6495
+ return;
6496
+ }
6497
+ if (!Array.isArray(patch.spansSequence) || patch.spansSequence.length === 0) {
6498
+ throw new Error("Rich-text patch spansSequence must be a non-empty array");
4344
6499
  }
4345
- for (const span of patch.spans) {
6500
+ for (const spans of patch.spansSequence) {
6501
+ if (!Array.isArray(spans)) {
6502
+ throw new Error("Rich-text patch spansSequence entries must be arrays");
6503
+ }
6504
+ this.validateRichTextSpans(spans);
6505
+ }
6506
+ }
6507
+ validateRichTextSpans(spans) {
6508
+ for (const span of spans) {
4346
6509
  if (!span || typeof span !== "object") {
4347
6510
  throw new Error("Rich-text span must be an object");
4348
6511
  }
@@ -4361,6 +6524,40 @@ class BaseMindooDB {
4361
6524
  throw new Error("Rich-text span type must be 'text' or 'block'");
4362
6525
  }
4363
6526
  }
6527
+ validateRichTextStepPatch(patch) {
6528
+ this.validateJsonPath(patch.path, "Rich-text steps patch");
6529
+ if (!Array.isArray(patch.steps) || patch.steps.length === 0) {
6530
+ throw new Error("Rich-text steps patch steps must be a non-empty array");
6531
+ }
6532
+ for (const step of patch.steps) {
6533
+ if (!step || typeof step !== "object") {
6534
+ throw new Error("Rich-text step must be an object");
6535
+ }
6536
+ if (step.type !== "splice") {
6537
+ throw new Error("Rich-text step type must be 'splice'");
6538
+ }
6539
+ if (!Number.isInteger(step.index) || step.index < 0) {
6540
+ throw new Error("Rich-text splice step index must be a non-negative integer");
6541
+ }
6542
+ if (!Number.isInteger(step.deleteCount) || step.deleteCount < 0) {
6543
+ throw new Error("Rich-text splice step deleteCount must be a non-negative integer");
6544
+ }
6545
+ if (step.insert !== undefined && typeof step.insert !== "string") {
6546
+ throw new Error("Rich-text splice step insert must be a string");
6547
+ }
6548
+ for (const markRange of step.marks ?? []) {
6549
+ if (!Number.isInteger(markRange.index) || markRange.index < 0) {
6550
+ throw new Error("Rich-text mark range index must be a non-negative integer");
6551
+ }
6552
+ if (!Number.isInteger(markRange.length) || markRange.length <= 0) {
6553
+ throw new Error("Rich-text mark range length must be a positive integer");
6554
+ }
6555
+ if (!markRange.marks || typeof markRange.marks !== "object" || Array.isArray(markRange.marks)) {
6556
+ throw new Error("Rich-text mark range marks must be an object");
6557
+ }
6558
+ }
6559
+ }
6560
+ }
4364
6561
  validateJsonPath(path, label) {
4365
6562
  if (!Array.isArray(path) || path.length === 0) {
4366
6563
  throw new Error(`${label} path must contain at least one segment`);
@@ -4454,6 +6651,53 @@ class BaseMindooDB {
4454
6651
  const list = this.ensureJsonListAtPath(automergeDoc, operation.path);
4455
6652
  list.splice(operation.index, 0, ...structuredClone(operation.values));
4456
6653
  }
6654
+ for (const operation of patch.textSplice ?? []) {
6655
+ this.spliceJsonTextAtPath(automergeDoc, operation.path, operation.index, operation.deleteCount, operation.insert ?? "");
6656
+ }
6657
+ for (const operation of patch.textMark ?? []) {
6658
+ for (const [name, value] of Object.entries(operation.marks)) {
6659
+ automerge_adapter_1.Automerge.mark(automergeDoc, operation.path, {
6660
+ start: operation.index,
6661
+ end: operation.index + operation.length,
6662
+ expand: "none",
6663
+ }, name, this.reviveRichTextValue(value));
6664
+ }
6665
+ }
6666
+ for (const operation of patch.textUnmark ?? []) {
6667
+ for (const name of operation.names) {
6668
+ this.unmarkJsonTextAtPath(automergeDoc, operation.path, operation.index, operation.length, name);
6669
+ }
6670
+ }
6671
+ }
6672
+ spliceJsonTextAtPath(automergeDoc, path, index, deleteCount, insert) {
6673
+ const value = this.readValueAtPath(automergeDoc, path);
6674
+ if (value === undefined || value === null) {
6675
+ this.setJsonValueAtPath(automergeDoc, path, "");
6676
+ }
6677
+ else if (typeof value !== "string") {
6678
+ throw new Error(`Cannot apply JSON textSplice to non-string value at '${path.map(String).join(".")}'`);
6679
+ }
6680
+ const text = this.readValueAtPath(automergeDoc, path);
6681
+ const length = typeof text === "string" ? text.length : 0;
6682
+ automerge_adapter_1.Automerge.splice(automergeDoc, path, this.clampIndex(index, length), Math.max(0, Math.min(deleteCount, length - this.clampIndex(index, length))), insert);
6683
+ }
6684
+ unmarkJsonTextAtPath(automergeDoc, path, index, length, name) {
6685
+ const range = {
6686
+ start: index,
6687
+ end: index + length,
6688
+ expand: "none",
6689
+ };
6690
+ if (typeof automerge_adapter_1.Automerge.unmark === "function") {
6691
+ automerge_adapter_1.Automerge.unmark(automergeDoc, path, range, name);
6692
+ return;
6693
+ }
6694
+ automerge_adapter_1.Automerge.mark(automergeDoc, path, range, name, null);
6695
+ }
6696
+ clampIndex(index, length) {
6697
+ if (!Number.isFinite(index)) {
6698
+ return length;
6699
+ }
6700
+ return Math.max(0, Math.min(Math.trunc(index), length));
4457
6701
  }
4458
6702
  setJsonValueAtPath(target, path, value) {
4459
6703
  const parent = this.ensureJsonParentAtPath(target, path);
@@ -4463,6 +6707,13 @@ class BaseMindooDB {
4463
6707
  const parent = this.readJsonParentAtPath(target, path);
4464
6708
  delete parent[path[path.length - 1]];
4465
6709
  }
6710
+ readValueAtPath(target, path) {
6711
+ let value = target;
6712
+ for (const segment of path) {
6713
+ value = value?.[segment];
6714
+ }
6715
+ return value;
6716
+ }
4466
6717
  readJsonListAtPath(target, path) {
4467
6718
  let value = target;
4468
6719
  for (const segment of path) {
@@ -4551,10 +6802,50 @@ class BaseMindooDB {
4551
6802
  target[leaf] = "";
4552
6803
  return;
4553
6804
  }
4554
- if (typeof currentValue !== "string") {
4555
- throw new Error(`Cannot apply text patch to non-string value at '${path.map(String).join(".")}'`);
6805
+ if (typeof currentValue !== "string") {
6806
+ throw new Error(`Cannot apply text patch to non-string value at '${path.map(String).join(".")}'`);
6807
+ }
6808
+ }
6809
+ /**
6810
+ * Run an Automerge change operation against `internalDoc.doc`, recovering
6811
+ * from the "outdated document" error by cloning the cached doc and
6812
+ * retrying once.
6813
+ *
6814
+ * Automerge marks a document as outdated whenever the JS reference has
6815
+ * already been passed to `Automerge.change` / `Automerge.applyChanges` /
6816
+ * `Automerge.changeAt` (the WASM handle's `state.heads` is set to a
6817
+ * non-null value, putting the doc into "view" mode). The mindoodb cache
6818
+ * normally avoids this by reassigning `internalDoc.doc = newDoc` after
6819
+ * every change, but a stale reference (left over from an aborted or
6820
+ * out-of-band Automerge operation) can still leak in. When that happens
6821
+ * the doc is still semantically up to date - it just can't be mutated -
6822
+ * and `Automerge.clone(...)` produces a fresh, writable copy at the same
6823
+ * heads. The same workaround already lives in
6824
+ * {@link applyNewEntriesToCachedDocument} (line ~6606) for the sync
6825
+ * pipeline; this helper centralizes it for the patch / changeDoc paths.
6826
+ */
6827
+ runChangeWithOutdatedDocRecovery(internalDoc, apply) {
6828
+ try {
6829
+ return apply(internalDoc.doc);
6830
+ }
6831
+ catch (error) {
6832
+ if (!this.isOutdatedDocumentError(error)) {
6833
+ throw error;
6834
+ }
6835
+ this.logger.warn(`Cached document ${internalDoc.id} was outdated for Automerge; cloning to recover and retrying change`);
6836
+ internalDoc.doc = automerge_adapter_1.Automerge.clone(internalDoc.doc);
6837
+ return apply(internalDoc.doc);
4556
6838
  }
4557
6839
  }
6840
+ isOutdatedDocumentError(error) {
6841
+ if (!(error instanceof Error))
6842
+ return false;
6843
+ return /outdated document/i.test(error.message);
6844
+ }
6845
+ isRichTextUpdateSpansBoundsError(error) {
6846
+ const message = error instanceof Error ? error.message : String(error);
6847
+ return /Cannot updateSpans|out of bounds/i.test(message);
6848
+ }
4558
6849
  ensureRichTextPath(automergeDoc, path) {
4559
6850
  let target = automergeDoc;
4560
6851
  for (let i = 0; i < path.length - 1; i++) {
@@ -4579,7 +6870,7 @@ class BaseMindooDB {
4579
6870
  }
4580
6871
  }
4581
6872
  async persistDocumentChange(options) {
4582
- const { internalDoc, newDoc, now, headsBeforeChange, useCustomKey, signingKeyPair, signingKeyPassword, successMessage, attachmentIds } = options;
6873
+ const { internalDoc, newDoc, now, headsBeforeChange, useCustomKey, signingKeyPair, signingKeyPassword, successMessage, attachmentIds, bypassPrecheck } = options;
4583
6874
  const docId = internalDoc.id;
4584
6875
  this.logger.debug(`Getting change bytes from document ${docId}`);
4585
6876
  const changesSincePreviousHeads = headsBeforeChange
@@ -4592,6 +6883,96 @@ class BaseMindooDB {
4592
6883
  if (!changeBytes) {
4593
6884
  throw new Error("Failed to get change bytes from Automerge document");
4594
6885
  }
6886
+ await this.persistAutomergeDocumentChanges({
6887
+ internalDoc,
6888
+ newDoc,
6889
+ now,
6890
+ changeBytesList: [changeBytes],
6891
+ useCustomKey,
6892
+ signingKeyPair,
6893
+ signingKeyPassword,
6894
+ successMessage,
6895
+ attachmentIds,
6896
+ bypassPrecheck,
6897
+ });
6898
+ }
6899
+ async persistAutomergeDocumentChanges(options) {
6900
+ const { internalDoc, newDoc, now, changeBytesList, headsBeforeChange, useCustomKey, signingKeyPair, signingKeyPassword, successMessage, attachmentIds, bypassPrecheck, } = options;
6901
+ const docId = internalDoc.id;
6902
+ const resolvedChangeBytesList = changeBytesList ?? (headsBeforeChange
6903
+ ? automerge_adapter_1.Automerge.getChangesSince(newDoc, headsBeforeChange)
6904
+ : []);
6905
+ if (resolvedChangeBytesList.length === 0) {
6906
+ throw new Error("No Automerge changes to persist");
6907
+ }
6908
+ // Signed snapshot of the document's attachments after this change, computed
6909
+ // once from the resulting document state so EVERY change path (changeDoc,
6910
+ // text patches, merges, ...) carries it consistently on each produced entry.
6911
+ const attachmentRefs = this.collectAttachmentRefs(newDoc);
6912
+ const entries = [];
6913
+ for (let index = 0; index < resolvedChangeBytesList.length; index += 1) {
6914
+ const changeBytes = resolvedChangeBytesList[index];
6915
+ const entry = await this.buildDocChangeStoreEntry({
6916
+ internalDoc,
6917
+ changeBytes,
6918
+ now,
6919
+ useCustomKey,
6920
+ signingKeyPair,
6921
+ signingKeyPassword,
6922
+ attachmentIds: index === 0 ? attachmentIds : undefined,
6923
+ // The full attachment snapshot describes the document's resulting state,
6924
+ // so it goes on every produced entry (unlike the add-only attachmentIds).
6925
+ attachmentRefs,
6926
+ });
6927
+ entries.push(entry);
6928
+ const decodedChange = automerge_adapter_1.Automerge.decodeChange(changeBytes);
6929
+ this.registerAutomergeHashMapping(docId, decodedChange.hash, entry.id);
6930
+ }
6931
+ // Client-side write-policy precheck (docs/accesscontrol.md §9). `internalDoc.doc`
6932
+ // is still the pre-change state here (it is reassigned to `newDoc` below,
6933
+ // after the store write), so before/after are both available for Tier 2
6934
+ // (`withfields`) evaluation. Throws AccessDeniedError when denied.
6935
+ {
6936
+ const signerKey = useCustomKey
6937
+ ? signingKeyPair.publicKey
6938
+ : (await this.tenant.getCurrentUserId()).userSigningPublicKey;
6939
+ const creatorKey = await this.resolveCreatorSigningKey(docId);
6940
+ const beforeState = internalDoc.doc;
6941
+ await this.assertWriteAllowed({
6942
+ op: "doc_change",
6943
+ signerKey,
6944
+ isAuthor: creatorKey !== null && signerKey === creatorKey,
6945
+ bypass: bypassPrecheck,
6946
+ getBeforeDoc: () => this.convertAutomergeToJS(beforeState),
6947
+ getAfterDoc: () => this.convertAutomergeToJS(newDoc),
6948
+ });
6949
+ }
6950
+ await this.store.putEntries(entries);
6951
+ internalDoc.doc = newDoc;
6952
+ internalDoc.lastModified = now;
6953
+ // The change entries just written are versioned and un-witnessed, so the
6954
+ // document is awaiting witness (until pushed + stamped with a receipt).
6955
+ internalDoc.awaitingWitness = entries.some(storeEntryTime_1.isProvisional) || (internalDoc.awaitingWitness ?? false);
6956
+ // `versioned` is sticky: any versioned entry (new or already reflected by a
6957
+ // prior awaiting/witnessed flag) keeps the doc in the witness-aware era.
6958
+ const versioned = entries.some(storeEntryTime_1.isVersioned)
6959
+ || internalDoc.awaitingWitness
6960
+ || (internalDoc.witnessed ?? false);
6961
+ internalDoc.witnessed = versioned && !internalDoc.awaitingWitness;
6962
+ await this.storeCachedDocument(internalDoc);
6963
+ this.updateIndex(docId, internalDoc.lastModified, internalDoc.isDeleted, internalDoc.decryptionKeyId, "visible", internalDoc.awaitingWitness, internalDoc.witnessed);
6964
+ this.markDocDirty(docId);
6965
+ this.logger.info(`Document ${docId} ${successMessage} successfully`);
6966
+ const createdByPublicKey = useCustomKey
6967
+ ? signingKeyPair?.publicKey
6968
+ : (await this.tenant.getCurrentUserId()).userSigningPublicKey;
6969
+ await this.maybeWriteSnapshotForDocument(internalDoc, useCustomKey
6970
+ ? { signingKeyPair, signingKeyPassword, createdByPublicKey: createdByPublicKey ?? "" }
6971
+ : { createdByPublicKey: createdByPublicKey ?? "" });
6972
+ }
6973
+ async buildDocChangeStoreEntry(options) {
6974
+ const { internalDoc, changeBytes, now, useCustomKey, signingKeyPair, signingKeyPassword, attachmentIds, attachmentRefs } = options;
6975
+ const docId = internalDoc.id;
4595
6976
  this.logger.debug(`Got change bytes: ${changeBytes.length} bytes`);
4596
6977
  const decodedChange = automerge_adapter_1.Automerge.decodeChange(changeBytes);
4597
6978
  const automergeHash = decodedChange.hash;
@@ -4628,22 +7009,15 @@ class BaseMindooDB {
4628
7009
  originalSize: changeBytes.length,
4629
7010
  encryptedSize: encryptedPayload.length,
4630
7011
  attachmentIds: attachmentIds && attachmentIds.length > 0 ? attachmentIds : undefined,
7012
+ attachmentRefs: attachmentRefs && attachmentRefs.length > 0 ? attachmentRefs : undefined,
7013
+ entryVersion: types_1.CURRENT_STORE_ENTRY_VERSION,
4631
7014
  };
4632
- const fullEntry = {
7015
+ // Bind the metadata with the author signature (audit finding #5).
7016
+ entryMetadata.metadataSignature = await this.computeEntryMetadataSignature(entryMetadata, useCustomKey ? { signingKeyPair, signingKeyPassword } : undefined);
7017
+ return {
4633
7018
  ...entryMetadata,
4634
7019
  encryptedData: encryptedPayload,
4635
7020
  };
4636
- await this.store.putEntries([fullEntry]);
4637
- this.registerAutomergeHashMapping(docId, automergeHash, entryId);
4638
- internalDoc.doc = newDoc;
4639
- internalDoc.lastModified = now;
4640
- await this.storeCachedDocument(internalDoc);
4641
- this.updateIndex(docId, internalDoc.lastModified, internalDoc.isDeleted, internalDoc.decryptionKeyId, "visible");
4642
- this.markDocDirty(docId);
4643
- this.logger.info(`Document ${docId} ${successMessage} successfully`);
4644
- await this.maybeWriteSnapshotForDocument(internalDoc, useCustomKey
4645
- ? { signingKeyPair, signingKeyPassword, createdByPublicKey }
4646
- : { createdByPublicKey });
4647
7021
  }
4648
7022
  /**
4649
7023
  * Best-effort snapshot scheduling and writing.
@@ -4653,6 +7027,18 @@ class BaseMindooDB {
4653
7027
  async maybeWriteSnapshotForDocument(internalDoc, options) {
4654
7028
  const docId = internalDoc.id;
4655
7029
  try {
7030
+ // Cheap pre-filter: while the in-memory write counter says the doc is
7031
+ // still below the snapshot threshold, skip the metadata scan entirely.
7032
+ // An absent counter (first write this session) forces one scan to learn
7033
+ // the real backlog, after which the counter tracks it incrementally.
7034
+ const knownWrites = this.writesSinceSnapshotCheck.get(docId);
7035
+ if (knownWrites !== undefined) {
7036
+ const writes = knownWrites + 1;
7037
+ if (writes < this.snapshotMinChanges) {
7038
+ this.writesSinceSnapshotCheck.set(docId, writes);
7039
+ return;
7040
+ }
7041
+ }
4656
7042
  const allMetadata = await this.scanAllMetadata(this.store, { docId });
4657
7043
  const replayEntries = allMetadata.filter((em) => this.isDocumentReplayEntry(em));
4658
7044
  const snapshots = allMetadata
@@ -4661,6 +7047,7 @@ class BaseMindooDB {
4661
7047
  const latestSnapshot = snapshots[0] || null;
4662
7048
  const latestSnapshotAt = latestSnapshot?.createdAt ?? 0;
4663
7049
  const changesSinceSnapshot = replayEntries.filter((em) => em.createdAt > latestSnapshotAt).length;
7050
+ this.writesSinceSnapshotCheck.set(docId, changesSinceSnapshot);
4664
7051
  if (changesSinceSnapshot < this.snapshotMinChanges) {
4665
7052
  return;
4666
7053
  }
@@ -4682,6 +7069,9 @@ class BaseMindooDB {
4682
7069
  else {
4683
7070
  signature = await this.tenant.signPayload(encryptedPayload);
4684
7071
  }
7072
+ // Carry the live attachment snapshot so the referenced set survives history
7073
+ // compaction (a snapshot may be the only entry a peer retains for the doc).
7074
+ const snapshotAttachmentRefs = this.collectAttachmentRefs(internalDoc.doc);
4685
7075
  const snapshotEntry = {
4686
7076
  entryType: "doc_snapshot",
4687
7077
  id: entryId,
@@ -4696,9 +7086,15 @@ class BaseMindooDB {
4696
7086
  signature,
4697
7087
  originalSize: snapshotBytes.length,
4698
7088
  encryptedSize: encryptedPayload.length,
7089
+ attachmentRefs: snapshotAttachmentRefs.length > 0 ? snapshotAttachmentRefs : undefined,
4699
7090
  encryptedData: encryptedPayload,
4700
7091
  };
7092
+ // Bind the metadata with the author signature (audit finding #5).
7093
+ snapshotEntry.metadataSignature = await this.computeEntryMetadataSignature(snapshotEntry, options.signingKeyPair && options.signingKeyPassword
7094
+ ? { signingKeyPair: options.signingKeyPair, signingKeyPassword: options.signingKeyPassword }
7095
+ : undefined);
4701
7096
  await this.store.putEntries([snapshotEntry]);
7097
+ this.writesSinceSnapshotCheck.set(docId, 0);
4702
7098
  this.logger.debug(`Created snapshot for document ${docId} with ${headHashes.length} heads and ${changesSinceSnapshot} changes since previous snapshot`);
4703
7099
  }
4704
7100
  catch (error) {
@@ -4715,7 +7111,10 @@ class BaseMindooDB {
4715
7111
  // materialize the entire remaining changefeed tail up front.
4716
7112
  for (let i = startIndex; i < indexSnapshot.length && docIds.length < this.iteratePrefetchWindowDocs; i++) {
4717
7113
  const entry = indexSnapshot[i];
4718
- if (entry.accessState === "inaccessible") {
7114
+ // Skip docs that iterateChangesSince will emit as lightweight
7115
+ // tombstones anyway: inaccessible docs are never materialized, and
7116
+ // deleted docs are yielded without body materialization.
7117
+ if (entry.accessState === "inaccessible" || entry.isDeleted) {
4719
7118
  continue;
4720
7119
  }
4721
7120
  const docId = entry.docId;
@@ -4964,7 +7363,7 @@ class BaseMindooDB {
4964
7363
  this.logger.warn(`Admin-only DB: skipping snapshot ${snapshotData.id} not signed by admin key`);
4965
7364
  }
4966
7365
  else {
4967
- isValid = await this.tenant.verifySignature(snapshotData.encryptedData, snapshotData.signature, snapshotData.createdByPublicKey);
7366
+ isValid = await this.tenant.verifyEntrySignature(snapshotData, snapshotData.encryptedData);
4968
7367
  }
4969
7368
  if (!isValid) {
4970
7369
  this.logger.warn(`Invalid signature for snapshot ${snapshotData.id}, falling back to replay without snapshot`);
@@ -4973,9 +7372,18 @@ class BaseMindooDB {
4973
7372
  else {
4974
7373
  const decryptedSnapshot = await this.tenant.decryptPayload(snapshotData.encryptedData, snapshotData.decryptionKeyId);
4975
7374
  doc = automerge_adapter_1.Automerge.load(decryptedSnapshot);
4976
- const parsed = (0, idGeneration_1.parseDocEntryId)(snapshotData.id);
4977
- if (parsed) {
4978
- this.registerAutomergeHashMapping(docId, parsed.automergeHash, snapshotData.id);
7375
+ // Snapshot-head verification (docs/accesscontrol.md §10).
7376
+ if (!(0, materializationGuard_1.snapshotHeadsMatch)(automerge_adapter_1.Automerge.getHeads(doc), snapshotData.snapshotHeadHashes)) {
7377
+ this.logger.warn(`Snapshot ${snapshotData.id} heads do not match declared snapshotHeadHashes, falling back to replay without snapshot`);
7378
+ doc = undefined;
7379
+ startFromSnapshot = false;
7380
+ }
7381
+ else {
7382
+ const parsed = (0, idGeneration_1.parseDocEntryId)(snapshotData.id);
7383
+ if (parsed) {
7384
+ this.registerAutomergeHashMapping(docId, parsed.automergeHash, snapshotData.id);
7385
+ }
7386
+ this.registerSnapshotHeadHashMappings(snapshotData);
4979
7387
  }
4980
7388
  }
4981
7389
  }
@@ -5000,7 +7408,7 @@ class BaseMindooDB {
5000
7408
  this.logger.warn(`Admin-only DB: skipping entry ${entryData.id} not signed by admin key`);
5001
7409
  continue;
5002
7410
  }
5003
- const isValid = await this.tenant.verifySignature(entryData.encryptedData, entryData.signature, entryData.createdByPublicKey);
7411
+ const isValid = await this.tenant.verifyEntrySignature(entryData, entryData.encryptedData);
5004
7412
  if (!isValid) {
5005
7413
  this.logger.warn(`Invalid signature for entry ${entryData.id}, skipping`);
5006
7414
  continue;
@@ -5015,6 +7423,7 @@ class BaseMindooDB {
5015
7423
  const orderedReplayEntries = [...replayEntriesForState].sort((left, right) => left.createdAt !== right.createdAt ? left.createdAt - right.createdAt : left.id.localeCompare(right.id));
5016
7424
  const firstReplayEntry = orderedReplayEntries[0] ?? null;
5017
7425
  const lastReplayEntry = orderedReplayEntries[orderedReplayEntries.length - 1] ?? null;
7426
+ const witnessState = (0, storeEntryTime_1.metadataWitnessState)(replayEntriesForState);
5018
7427
  return {
5019
7428
  id: docId,
5020
7429
  doc,
@@ -5022,6 +7431,8 @@ class BaseMindooDB {
5022
7431
  lastModified: lastReplayEntry?.createdAt ?? snapshotMeta?.createdAt ?? fallbackTimestamp,
5023
7432
  decryptionKeyId: firstReplayEntry?.decryptionKeyId ?? snapshotMeta?.decryptionKeyId ?? "default",
5024
7433
  isDeleted: this.computeIsDeletedFromMetadata(replayEntriesForState),
7434
+ awaitingWitness: witnessState.awaitingWitness,
7435
+ witnessed: witnessState.witnessed,
5025
7436
  };
5026
7437
  }
5027
7438
  async *iterateChangeMetadataSince(cursor) {
@@ -5073,6 +7484,20 @@ class BaseMindooDB {
5073
7484
  docId: latestEntry.docId,
5074
7485
  };
5075
7486
  }
7487
+ /**
7488
+ * Count the changefeed entries after the given cursor without iterating
7489
+ * them. O(log n) binary search on the internal `(changeSeq, docId)` index —
7490
+ * used for progress reporting around {@link iterateChangesSince}.
7491
+ */
7492
+ countChangesSince(cursor) {
7493
+ const actualCursor = cursor ?? {
7494
+ changeSeq: 0,
7495
+ lastModified: 0,
7496
+ docId: "",
7497
+ };
7498
+ const startIndex = this.getStartIndexForCursor(this.index, actualCursor);
7499
+ return this.index.length - startIndex;
7500
+ }
5076
7501
  async *iterateChangesSince(cursor) {
5077
7502
  const startedAt = Date.now();
5078
7503
  // Default to initial cursor if null is provided.
@@ -5093,6 +7518,37 @@ class BaseMindooDB {
5093
7518
  for (let i = startIndex; i < indexSnapshot.length; i++) {
5094
7519
  const entry = indexSnapshot[i];
5095
7520
  if (entry.accessState !== "visible") {
7521
+ // The document was readable before but the current KeyBag can no
7522
+ // longer decrypt it (e.g. a revoked key, §13). Emit a tombstone so
7523
+ // incremental consumers get a removal signal — distinguishable from a
7524
+ // genuine deletion via isAccessible() === false (parity with
7525
+ // iterateChangeMetadataSince, which already emits isDeleted: true for
7526
+ // these). The body is never materialized.
7527
+ const inaccessibleCursor = {
7528
+ changeSeq: entry.changeSeq,
7529
+ lastModified: entry.lastModified,
7530
+ docId: entry.docId,
7531
+ };
7532
+ yieldedDocuments++;
7533
+ yield { doc: this.buildInaccessibleDoc(entry), cursor: inaccessibleCursor };
7534
+ prefetchedDocuments += await this.prefetchIterationWindow(indexSnapshot, i + 1);
7535
+ continue;
7536
+ }
7537
+ if (entry.isDeleted) {
7538
+ // Deleted documents are emitted as lightweight tombstones without
7539
+ // materializing the body — consistent with the inaccessible case
7540
+ // above (isDeleted() === true, getData() === {}), distinguishable
7541
+ // via isAccessible() === true. Consumers that need the last state
7542
+ // of a deleted doc can use iterateDocumentHistory /
7543
+ // getDocumentAtTimestamp.
7544
+ const deletedCursor = {
7545
+ changeSeq: entry.changeSeq,
7546
+ lastModified: entry.lastModified,
7547
+ docId: entry.docId,
7548
+ };
7549
+ yieldedDocuments++;
7550
+ yield { doc: this.buildDeletedDoc(entry), cursor: deletedCursor };
7551
+ prefetchedDocuments += await this.prefetchIterationWindow(indexSnapshot, i + 1);
5096
7552
  continue;
5097
7553
  }
5098
7554
  try {
@@ -5208,7 +7664,7 @@ class BaseMindooDB {
5208
7664
  // Key was not trusted or failed to import
5209
7665
  return { entryData, isValid: false };
5210
7666
  }
5211
- const isValid = await this.verifySignatureWithKey(cryptoKey, entryData.encryptedData, entryData.signature);
7667
+ const isValid = await this.verifyEntrySignatureWithKey(cryptoKey, entryData);
5212
7668
  return { entryData, isValid };
5213
7669
  }));
5214
7670
  // Filter out entries with invalid signatures
@@ -5248,17 +7704,29 @@ class BaseMindooDB {
5248
7704
  // Check if document actually changed
5249
7705
  const headsAfter = automerge_adapter_1.Automerge.getHeads(updatedDoc);
5250
7706
  const headsChanged = JSON.stringify(headsBefore) !== JSON.stringify(headsAfter);
5251
- if (!headsChanged) {
5252
- this.logger.debug(`Document ${docId} heads unchanged after applying new entries`);
7707
+ // A witness receipt changes only an entry's `receivedAt` (metadata), not the
7708
+ // Automerge content, so a freshly-witnessed entry re-delivered by the cursor
7709
+ // scan leaves the heads UNCHANGED. We must still recompute the per-doc
7710
+ // "awaiting witness" flag and refresh the index/cache so the doc is
7711
+ // re-delivered on the metadata feed and re-evaluated by VirtualViews;
7712
+ // otherwise the flag would never clear. So we compute it up front (reusing
7713
+ // the metadata scan we need anyway) and only bail when NOTHING observable
7714
+ // changed — neither the content nor the witness status.
7715
+ const allDocMetadata = await this.scanAllMetadata(this.store, { docId });
7716
+ const { awaitingWitness, witnessed } = (0, storeEntryTime_1.metadataWitnessState)(allDocMetadata);
7717
+ const awaitingWitnessChanged = awaitingWitness !== (cachedDoc.awaitingWitness ?? false);
7718
+ const witnessedChanged = witnessed !== (cachedDoc.witnessed ?? false);
7719
+ if (!headsChanged && !awaitingWitnessChanged && !witnessedChanged) {
7720
+ this.logger.debug(`Document ${docId} heads and witness status unchanged after applying new entries`);
5253
7721
  return null; // Document didn't actually change
5254
7722
  }
5255
- this.logger.debug(`Document ${docId} changed: heads before=${JSON.stringify(headsBefore)}, after=${JSON.stringify(headsAfter)}`);
7723
+ if (this.isDebugEnabled())
7724
+ this.logger.debug(`Document ${docId} changed: heads before=${JSON.stringify(headsBefore)}, after=${JSON.stringify(headsAfter)}, awaitingWitness=${awaitingWitness}`);
5256
7725
  // Update metadata
5257
7726
  const payload = updatedDoc;
5258
7727
  const lastEntryCreatedAt = entries.reduce((maxCreatedAt, entry) => Math.max(maxCreatedAt, entry.createdAt), cachedDoc.lastModified);
5259
7728
  const lastModified = payload._lastModified ||
5260
7729
  lastEntryCreatedAt;
5261
- const allDocMetadata = await this.scanAllMetadata(this.store, { docId });
5262
7730
  const isDeleted = this.computeIsDeletedFromMetadata(allDocMetadata);
5263
7731
  const updatedInternalDoc = {
5264
7732
  id: docId,
@@ -5267,6 +7735,8 @@ class BaseMindooDB {
5267
7735
  lastModified,
5268
7736
  decryptionKeyId: cachedDoc.decryptionKeyId,
5269
7737
  isDeleted,
7738
+ awaitingWitness,
7739
+ witnessed,
5270
7740
  };
5271
7741
  // Update cache
5272
7742
  await this.storeCachedDocument(updatedInternalDoc);
@@ -5324,6 +7794,7 @@ class BaseMindooDB {
5324
7794
  this.logger.warn(`L2 deserialize failed for ${docId}, evicting stale record: ${e}`);
5325
7795
  try {
5326
7796
  await store.delete("doc", key);
7797
+ this.lastFlushedDocState.delete(docId);
5327
7798
  }
5328
7799
  catch (deleteError) {
5329
7800
  this.logger.warn(`Failed to evict corrupt L2 record for ${docId}: ${deleteError}`);
@@ -5343,13 +7814,14 @@ class BaseMindooDB {
5343
7814
  await this.purgeMaterializedDocument(docId);
5344
7815
  return null;
5345
7816
  }
5346
- const indexEntryIdx = this.indexLookup.get(docId);
7817
+ const indexEntryIdx = this.getDocIndexPosition(docId);
5347
7818
  if (indexEntryIdx === undefined) {
5348
7819
  // Doc isn't in the in-memory changefeed index. Either the cache is
5349
7820
  // ahead of the index (very rare race during initial restore) or the
5350
7821
  // L2 record is orphaned. Drop and fall through.
5351
7822
  try {
5352
7823
  await store.delete("doc", key);
7824
+ this.lastFlushedDocState.delete(docId);
5353
7825
  }
5354
7826
  catch (deleteError) {
5355
7827
  this.logger.warn(`Failed to evict orphaned L2 record for ${docId}: ${deleteError}`);
@@ -5359,6 +7831,9 @@ class BaseMindooDB {
5359
7831
  const currentChangeSeq = this.index[indexEntryIdx].changeSeq;
5360
7832
  // Fresh hit: index agrees with the persisted state.
5361
7833
  if (persistedChangeSeq === currentChangeSeq) {
7834
+ // Seed the flush-skip fingerprint with the state just read from L2 so
7835
+ // the next periodic flush doesn't rewrite an identical record.
7836
+ this.lastFlushedDocState.set(docId, `${persistedChangeSeq}:${automerge_adapter_1.Automerge.getHeads(internal.doc).join(",")}`);
5362
7837
  await this.storeCachedDocument(internal);
5363
7838
  return internal;
5364
7839
  }
@@ -5619,6 +8094,491 @@ class BaseMindooDB {
5619
8094
  emitProgress({ processed, total, phase: "cancelled" });
5620
8095
  }
5621
8096
  }
8097
+ /**
8098
+ * The per-tenant quarantine/audit log: entries rejected during
8099
+ * materialization by the access-control layer (docs/accesscontrol.md §10).
8100
+ * Exposed for audit views (e.g. Haven). Entries remain in the append-only
8101
+ * store but are excluded from materialized state and queries.
8102
+ */
8103
+ getQuarantineLog() {
8104
+ return this.quarantineLog;
8105
+ }
8106
+ /**
8107
+ * Whether access control is active for this tenant and should gate
8108
+ * materialization. Admin-only databases (incl. the directory itself) are
8109
+ * always exempt — admin is the root of trust, and exempting the directory DB
8110
+ * also avoids re-entrancy. Cached briefly to keep the load fast-path cheap.
8111
+ * Fails closed-to-disabled on any directory error so document loads never
8112
+ * break due to a transient directory issue (Tier 2 only gates honest clients).
8113
+ */
8114
+ async isAclEnforced() {
8115
+ if (this._isAdminOnlyDb)
8116
+ return false;
8117
+ const now = Date.now();
8118
+ if (this.aclActiveCache && now - this.aclActiveCache.at < 3000) {
8119
+ return this.aclActiveCache.value;
8120
+ }
8121
+ try {
8122
+ const directory = await this.tenant.openDirectory();
8123
+ let value = false;
8124
+ if (typeof directory.isAccessControlActive === "function") {
8125
+ value = await directory.isAccessControlActive();
8126
+ }
8127
+ this.aclActiveCache = { value, at: now };
8128
+ return value;
8129
+ }
8130
+ catch (error) {
8131
+ // Fail closed (audit finding #2): a transient directory error must never
8132
+ // silently DISABLE access control (which would bypass the fail-closed
8133
+ // materialization gate entirely). Reuse the last known verdict if we have
8134
+ // one; otherwise assume enforced so the materialization path governs the
8135
+ // load and retries once the directory is reachable again.
8136
+ if (this.aclActiveCache) {
8137
+ this.logger.debug(`[ACL] active-check failed, reusing last known verdict (${this.aclActiveCache.value}): ${error}`);
8138
+ return this.aclActiveCache.value;
8139
+ }
8140
+ this.logger.warn(`[ACL] active-check failed with no prior verdict, assuming enforced (fail closed): ${error}`);
8141
+ return true;
8142
+ }
8143
+ }
8144
+ /**
8145
+ * Synchronous client-side write-policy precheck (docs/accesscontrol.md §9).
8146
+ * Evaluates the full Tier 1 + Tier 2 ruleset for the candidate write and
8147
+ * throws {@link AccessDeniedError} when it is denied, so applications get
8148
+ * immediate, meaningful feedback at the call site instead of an optimistic
8149
+ * local write that is later rejected by the server witness or quarantined on
8150
+ * materialization.
8151
+ *
8152
+ * Fails CLOSED on any directory/infra error while access control is enforced
8153
+ * (audit finding #2): the precheck denies the write rather than letting it
8154
+ * through optimistically. The server witness (Tier 1) and
8155
+ * quarantine-on-materialization (Tier 2) remain the authoritative enforcers,
8156
+ * and the user can retry once the directory is reachable again.
8157
+ *
8158
+ * `getBeforeDoc`/`getAfterDoc` are lazy so the (potentially costly) Automerge
8159
+ * -> JS materialization is skipped entirely when the active policy has no
8160
+ * Tier 2 (`withfields`) content rule for this operation/database.
8161
+ */
8162
+ async assertWriteAllowed(input) {
8163
+ if (input.bypass)
8164
+ return;
8165
+ const decision = await this.evaluateClientWriteAccess(input);
8166
+ if (decision && !decision.allowed) {
8167
+ throw new AccessDeniedError_1.AccessDeniedError(input.op, this.store.getId(), decision);
8168
+ }
8169
+ }
8170
+ /**
8171
+ * Resolve the `decryptionKeyId` for a `doc_create`: the caller's explicit key
8172
+ * when given, else the policy-configured default for this database (see
8173
+ * `getEffectiveDefaultCreateKeyId` on the directory), else the legacy
8174
+ * `"default"`. The policy default is a create-time convenience only; read/write
8175
+ * access is governed by key possession (distribution + rotation, §13), not the
8176
+ * server. Directory ACL writes always pass an explicit key, so this never
8177
+ * re-enters the directory for them.
8178
+ */
8179
+ async resolveCreateKeyId(options) {
8180
+ if (options.decryptionKeyId !== undefined)
8181
+ return options.decryptionKeyId;
8182
+ try {
8183
+ const directory = await this.tenant.openDirectory();
8184
+ const getEffectiveDefaultCreateKeyId = directory.getEffectiveDefaultCreateKeyId?.bind(directory);
8185
+ if (getEffectiveDefaultCreateKeyId) {
8186
+ const fromPolicy = await getEffectiveDefaultCreateKeyId(this.store.getId());
8187
+ if (fromPolicy !== undefined)
8188
+ return fromPolicy;
8189
+ }
8190
+ }
8191
+ catch (error) {
8192
+ this.logger.debug(`[ACL] default create-key lookup skipped (directory error): ${error}`);
8193
+ }
8194
+ return "default";
8195
+ }
8196
+ /**
8197
+ * Evaluate the full client-side (Tier 1 + Tier 2) write ruleset for a
8198
+ * candidate write, returning the {@link AccessDecision}. Returns `null` only
8199
+ * when access control is not enforced for this database (callers treat `null`
8200
+ * as "allowed"). When access control IS enforced but the directory cannot be
8201
+ * consulted, it returns a DENY decision (fail closed, audit finding #2). The
8202
+ * `getBeforeDoc`/`getAfterDoc` materializers are only invoked when the active
8203
+ * policy has a Tier 2 (`withfields`) content rule for this op/db.
8204
+ */
8205
+ async evaluateClientWriteAccess(input) {
8206
+ if (!(await this.isAclEnforced()))
8207
+ return null;
8208
+ const dbid = this.store.getId();
8209
+ try {
8210
+ const directory = await this.tenant.openDirectory();
8211
+ const evaluateClientAccess = directory.evaluateClientAccess?.bind(directory);
8212
+ if (!evaluateClientAccess)
8213
+ return null;
8214
+ // Only materialize the before/after documents when a content rule needs
8215
+ // them; pure Tier 1 (identity/op) checks never read the doc.
8216
+ let needContent = true;
8217
+ if (typeof directory.hasWriteContentRules === "function") {
8218
+ needContent = await directory.hasWriteContentRules(input.op, dbid);
8219
+ }
8220
+ const beforeDoc = needContent && input.getBeforeDoc ? input.getBeforeDoc() : null;
8221
+ const afterDoc = needContent && input.getAfterDoc ? input.getAfterDoc() : null;
8222
+ return await evaluateClientAccess({
8223
+ op: input.op,
8224
+ dbid,
8225
+ signingKey: input.signerKey,
8226
+ trustedTime: Date.now(),
8227
+ isAuthor: input.isAuthor,
8228
+ beforeDoc,
8229
+ afterDoc,
8230
+ });
8231
+ }
8232
+ catch (error) {
8233
+ // Fail closed (audit finding #2): access control IS enforced here (checked
8234
+ // above), so a directory error during the write precheck must not let the
8235
+ // write through optimistically. Deny now; the user can retry once the
8236
+ // directory is reachable again. The server witness remains the
8237
+ // authoritative backstop.
8238
+ this.logger.warn(`[ACL] write precheck failed, denying (fail closed): ${error}`);
8239
+ return {
8240
+ allowed: false,
8241
+ reason: "directory unavailable during write precheck; failing closed",
8242
+ tier: "tier1",
8243
+ };
8244
+ }
8245
+ }
8246
+ /**
8247
+ * Predict whether a `createDocument` would be allowed by the active write
8248
+ * policy, without writing anything (docs/accesscontrol.md §9). Lets a UI
8249
+ * disable a Save action and surface `decision.reason` up front. The returned
8250
+ * decision mirrors what {@link createDocument} would enforce.
8251
+ */
8252
+ async canCreate(options = {}) {
8253
+ const keyId = await this.resolveCreateKeyId(options);
8254
+ const useCustomSigningKey = options.signingKeyPair !== undefined && options.signingKeyPassword !== undefined;
8255
+ const signerKey = useCustomSigningKey
8256
+ ? options.signingKeyPair.publicKey
8257
+ : (await this.tenant.getCurrentUserId()).userSigningPublicKey;
8258
+ const afterValues = { _attachments: [] };
8259
+ if (options.initialValues) {
8260
+ for (const [k, v] of Object.entries(options.initialValues)) {
8261
+ if (!k.startsWith("_"))
8262
+ afterValues[k] = v;
8263
+ }
8264
+ }
8265
+ const decision = await this.evaluateClientWriteAccess({
8266
+ op: "doc_create",
8267
+ signerKey,
8268
+ isAuthor: true,
8269
+ getAfterDoc: () => afterValues,
8270
+ });
8271
+ return decision ?? BaseMindooDB.ACL_NOT_ENFORCED_DECISION;
8272
+ }
8273
+ /**
8274
+ * Predict whether applying `candidateAfter` as the document's next state via
8275
+ * {@link changeDoc} would be allowed by the active write policy, without
8276
+ * writing anything. `candidateAfter` is the intended full "after" document
8277
+ * (e.g. the edited JSON in a database-browser editor).
8278
+ */
8279
+ async canChange(doc, candidateAfter, signingKeyPair) {
8280
+ const docId = doc.getId();
8281
+ const signerKey = signingKeyPair
8282
+ ? signingKeyPair.publicKey
8283
+ : (await this.tenant.getCurrentUserId()).userSigningPublicKey;
8284
+ const creatorKey = await this.resolveCreatorSigningKey(docId);
8285
+ const before = doc.getData();
8286
+ const decision = await this.evaluateClientWriteAccess({
8287
+ op: "doc_change",
8288
+ signerKey,
8289
+ isAuthor: creatorKey !== null && signerKey === creatorKey,
8290
+ getBeforeDoc: () => before,
8291
+ getAfterDoc: () => candidateAfter,
8292
+ });
8293
+ return decision ?? BaseMindooDB.ACL_NOT_ENFORCED_DECISION;
8294
+ }
8295
+ /**
8296
+ * Predict whether deleting `doc` via {@link deleteDocument} would be allowed
8297
+ * by the active write policy, without writing anything.
8298
+ */
8299
+ async canDelete(doc, signingKeyPair) {
8300
+ const docId = doc.getId();
8301
+ const signerKey = signingKeyPair
8302
+ ? signingKeyPair.publicKey
8303
+ : (await this.tenant.getCurrentUserId()).userSigningPublicKey;
8304
+ const creatorKey = await this.resolveCreatorSigningKey(docId);
8305
+ const current = doc.getData();
8306
+ const decision = await this.evaluateClientWriteAccess({
8307
+ op: "doc_delete",
8308
+ signerKey,
8309
+ isAuthor: creatorKey !== null && signerKey === creatorKey,
8310
+ getBeforeDoc: () => current,
8311
+ getAfterDoc: () => current,
8312
+ });
8313
+ return decision ?? BaseMindooDB.ACL_NOT_ENFORCED_DECISION;
8314
+ }
8315
+ /**
8316
+ * Predict whether undeleting `doc` via {@link undeleteDocument} would be
8317
+ * allowed by the active write policy, without writing anything.
8318
+ */
8319
+ async canUndelete(doc, signingKeyPair) {
8320
+ const docId = doc.getId();
8321
+ const signerKey = signingKeyPair
8322
+ ? signingKeyPair.publicKey
8323
+ : (await this.tenant.getCurrentUserId()).userSigningPublicKey;
8324
+ const creatorKey = await this.resolveCreatorSigningKey(docId);
8325
+ const current = doc.getData();
8326
+ const decision = await this.evaluateClientWriteAccess({
8327
+ op: "doc_undelete",
8328
+ signerKey,
8329
+ isAuthor: creatorKey !== null && signerKey === creatorKey,
8330
+ getBeforeDoc: () => current,
8331
+ getAfterDoc: () => current,
8332
+ });
8333
+ return decision ?? BaseMindooDB.ACL_NOT_ENFORCED_DECISION;
8334
+ }
8335
+ /**
8336
+ * Resolve the signing public key of the document's original creator (the
8337
+ * `doc_create` entry's `createdByPublicKey`), used to set `$author`
8338
+ * (`isAuthor`) for the client write prechecks on change/delete/undelete.
8339
+ * Cached per document; returns `null` if the create entry isn't present yet
8340
+ * (in which case `isAuthor` is treated as false, matching materialization).
8341
+ */
8342
+ async resolveCreatorSigningKey(docId) {
8343
+ const cached = this.creatorKeyCache.get(docId);
8344
+ if (cached)
8345
+ return cached;
8346
+ try {
8347
+ const metas = await this.scanAllMetadata(this.store, { docId });
8348
+ const createKey = metas.find((m) => m.entryType === "doc_create")?.createdByPublicKey ?? null;
8349
+ if (createKey)
8350
+ this.creatorKeyCache.set(docId, createKey);
8351
+ return createKey;
8352
+ }
8353
+ catch (error) {
8354
+ this.logger.debug(`[ACL] creator-key resolution failed for ${docId}: ${error}`);
8355
+ return null;
8356
+ }
8357
+ }
8358
+ /** Append a record to the quarantine/audit log (and log it). */
8359
+ recordQuarantine(rec) {
8360
+ this.quarantineLog.push(rec);
8361
+ console.log(`[MindooDB][ACL] Quarantined entry ${rec.entryId} (doc ${rec.docId}): ${rec.reason} — ${rec.detail}`);
8362
+ }
8363
+ /**
8364
+ * Materialize a document through the access-control (Tier 2) path
8365
+ * (docs/accesscontrol.md §10). Entries are evaluated deterministically in
8366
+ * trusted-time order; an entry is quarantined if it violates a rule, fails
8367
+ * op-type re-derivation, or causally depends on a quarantined entry
8368
+ * (cascade). The accepted set is therefore a causally-closed prefix identical
8369
+ * across replicas, which is what makes "quarantine on receipt" safe under
8370
+ * eventual consistency.
8371
+ *
8372
+ * Snapshots are intentionally bypassed here and the document is rebuilt from
8373
+ * individually-verified lifecycle entries (the spec's fallback), so a snapshot
8374
+ * can never smuggle content past Tier 2.
8375
+ *
8376
+ * @returns the materialized {@link InternalDoc} (or `null` if nothing is
8377
+ * accepted / the document is absent), or `undefined` when access control is
8378
+ * not enforced (caller uses the optimized fast path).
8379
+ */
8380
+ async maybeMaterializeWithAccessControl(docId, allEntryMetadata) {
8381
+ if (!(await this.isAclEnforced()))
8382
+ return undefined;
8383
+ // Fail-closed self-healing (audit finding #2): when a validation step cannot
8384
+ // be completed (directory/witness adapter threw), we quarantine the affected
8385
+ // entry for THIS pass but must not cache the result, so the next load retries
8386
+ // and recovers once the dependency is healthy again. `cacheable` is flipped
8387
+ // to false the moment any transient failure occurs.
8388
+ let cacheable = true;
8389
+ const dbid = this.store.getId();
8390
+ const directory = await this.tenant.openDirectory();
8391
+ // Capture bound locals: these optional members are called later inside an
8392
+ // await-heavy loop, where TS would otherwise lose the property narrowing.
8393
+ const evaluateClientAccess = directory.evaluateClientAccess?.bind(directory);
8394
+ if (!evaluateClientAccess)
8395
+ return undefined;
8396
+ const getTrustedWitnessKeysAt = directory.getTrustedWitnessKeysAt?.bind(directory);
8397
+ // One verifier per materialization enforces per-witness `receivedAt`
8398
+ // monotonicity across the entries we accept, in trusted-time order (§5.4).
8399
+ // A verify-only provider (no signer) routes receipts to the right scheme.
8400
+ const subtle = this.getSubtle();
8401
+ const receiptVerifier = new Ed25519WitnessProvider_1.Ed25519WitnessProvider({ subtle }).createVerifier();
8402
+ const nowMs = Date.now();
8403
+ // Lifecycle entries only, folded in deterministic causal order. Un-witnessed
8404
+ // entries (versioned or legacy) order by their author time `createdAt`, NOT
8405
+ // the provisional `now`: this fold computes per-step before/after states for
8406
+ // Tier 2 evaluation, so it must preserve authoring order rather than
8407
+ // collapsing every still-provisional entry onto a single `now` (which would
8408
+ // tie-break by entry id and scramble causal sequence). The version-aware
8409
+ // head/`now` selection only matters for the changefeed stamp and the
8410
+ // judgment-site node selection below, not for this fold order.
8411
+ const dataEntries = allEntryMetadata
8412
+ .filter((m) => this.isDocumentReplayEntry(m))
8413
+ .sort((a, b) => (a.receivedAt ?? a.createdAt) === (b.receivedAt ?? b.createdAt)
8414
+ ? a.id.localeCompare(b.id)
8415
+ : (a.receivedAt ?? a.createdAt) - (b.receivedAt ?? b.createdAt));
8416
+ if (dataEntries.length === 0)
8417
+ return { doc: null, cacheable };
8418
+ const creatorKey = dataEntries.find((m) => m.entryType === "doc_create")?.createdByPublicKey ?? null;
8419
+ const fetched = await this.store.getEntries(dataEntries.map((m) => m.id));
8420
+ const entryById = new Map(fetched.map((e) => [e.id, e]));
8421
+ let currentDoc = null;
8422
+ let createdAt = null;
8423
+ let decryptionKeyId = "default";
8424
+ const acceptedMeta = [];
8425
+ const quarantined = new Set();
8426
+ const quarantine = (meta, reason, detail) => {
8427
+ quarantined.add(meta.id);
8428
+ this.recordQuarantine({
8429
+ entryId: meta.id,
8430
+ docId,
8431
+ dbid,
8432
+ entryType: meta.entryType,
8433
+ reason,
8434
+ detail,
8435
+ // Audit record: the entry's own (stable) time — receipt time if
8436
+ // witnessed, else author time. Not the provisional head `now`.
8437
+ trustedTime: meta.receivedAt ?? meta.createdAt,
8438
+ recordedAt: Date.now(),
8439
+ });
8440
+ };
8441
+ for (const meta of dataEntries) {
8442
+ const entry = entryById.get(meta.id);
8443
+ if (!entry)
8444
+ continue;
8445
+ // Admin-only DBs are exempt (handled by isAclEnforced); here we honor the
8446
+ // tenant admin key as always-trusted via the directory identity set.
8447
+ // Cascade: any quarantined dependency taints this entry (§10).
8448
+ if (meta.dependencyIds.some((dep) => quarantined.has(dep))) {
8449
+ quarantine(meta, "cascade_dependent", "causally depends on a quarantined entry");
8450
+ continue;
8451
+ }
8452
+ // Verify the author signature over the encrypted payload.
8453
+ const cryptoKey = await this.getOrImportPublicKey(entry.createdByPublicKey);
8454
+ if (!cryptoKey || !(await this.verifyEntrySignatureWithKey(cryptoKey, entry))) {
8455
+ quarantine(meta, "invalid_signature", "signature verification failed");
8456
+ continue;
8457
+ }
8458
+ // Validate the witness receipt, if any (§5.4): a present receipt must come
8459
+ // from a currently-trusted witness, carry a valid signature, and respect
8460
+ // per-witness monotonicity and the future-time bound. Entries with no
8461
+ // receipt are local/not-yet-witnessed and pass this check.
8462
+ if (getTrustedWitnessKeysAt && (meta.receivedAt !== undefined || meta.receivedByPublicKey)) {
8463
+ try {
8464
+ const trustedWitnessKeys = await getTrustedWitnessKeysAt(meta.receivedAt ?? meta.createdAt);
8465
+ const receiptResult = await receiptVerifier.validate(meta, { dbid, trustedWitnessKeys, nowMs });
8466
+ if (!receiptResult.ok) {
8467
+ quarantine(meta, "invalid_witness_receipt", receiptResult.reason ?? "invalid witness receipt");
8468
+ continue;
8469
+ }
8470
+ }
8471
+ catch (error) {
8472
+ // Fail closed (audit finding #2): a malformed receipt or adapter bug
8473
+ // must NOT bypass the witness check. Quarantine this entry for now and
8474
+ // mark the result non-cacheable so a later load retries once the
8475
+ // verifier is healthy again.
8476
+ this.logger.warn(`[ACL] receipt validation failed for ${meta.id}, quarantining (will retry): ${error}`);
8477
+ cacheable = false;
8478
+ quarantine(meta, "directory_unavailable", `witness receipt validation error: ${error}`);
8479
+ continue;
8480
+ }
8481
+ }
8482
+ const isGenesis = currentDoc === null;
8483
+ if (isGenesis && meta.entryType !== "doc_create") {
8484
+ // Not a valid document start (mirrors the fast path); skip.
8485
+ continue;
8486
+ }
8487
+ // Op-type defense in depth (§10): a doc_create must be a genesis change.
8488
+ if (meta.entryType === "doc_create" && !isGenesis) {
8489
+ quarantine(meta, "op_type_mismatch", "signed doc_create but not a genesis change");
8490
+ continue;
8491
+ }
8492
+ // Decrypt and compute the candidate (after) state.
8493
+ const decrypted = await this.tenant.decryptPayload(entry.encryptedData, entry.decryptionKeyId);
8494
+ const baseDoc = currentDoc ?? automerge_adapter_1.Automerge.init();
8495
+ let afterDoc;
8496
+ try {
8497
+ afterDoc = automerge_adapter_1.Automerge.loadIncremental(automerge_adapter_1.Automerge.clone(baseDoc), decrypted);
8498
+ }
8499
+ catch (error) {
8500
+ quarantine(meta, "op_type_mismatch", `failed to decode change: ${error}`);
8501
+ continue;
8502
+ }
8503
+ const beforeJS = currentDoc
8504
+ ? this.convertAutomergeToJS(currentDoc)
8505
+ : null;
8506
+ const afterJS = this.convertAutomergeToJS(afterDoc);
8507
+ const isAuthor = creatorKey !== null && entry.createdByPublicKey === creatorKey;
8508
+ // Trusted-time rule (docs/accesscontrol.md §8, version-aware): a witnessed
8509
+ // entry is judged against the directory as it provably was at its
8510
+ // `receivedAt`. A versioned un-witnessed entry (provisional) has no
8511
+ // provable position, so it is judged against the current directory HEAD —
8512
+ // `MAX_SAFE_INTEGER` selects the head node deterministically regardless of
8513
+ // where the directory's own provisional (un-witnessed) entries currently
8514
+ // float, which a wall-clock `now` would not (those entries can sit at a
8515
+ // later `now`). A legacy un-witnessed entry predates witnessing and is
8516
+ // stable at its `createdAt`, so it is judged against the historical
8517
+ // directory state in effect then. See {@link isProvisional}.
8518
+ const trustedTime = (0, storeEntryTime_1.isProvisional)(meta)
8519
+ ? Number.MAX_SAFE_INTEGER
8520
+ : (meta.receivedAt ?? meta.createdAt);
8521
+ let decision;
8522
+ try {
8523
+ decision = await evaluateClientAccess({
8524
+ op: meta.entryType,
8525
+ dbid,
8526
+ signingKey: entry.createdByPublicKey,
8527
+ trustedTime,
8528
+ isAuthor,
8529
+ beforeDoc: beforeJS,
8530
+ afterDoc: afterJS,
8531
+ });
8532
+ }
8533
+ catch (error) {
8534
+ // Fail closed (audit finding #2): a transient directory error must not
8535
+ // silently materialize an entry the policy might reject. Quarantine it
8536
+ // for this pass and mark the result non-cacheable so the next load
8537
+ // retries and self-heals once the directory is reachable again.
8538
+ this.logger.warn(`[ACL] evaluation failed for ${meta.id}, quarantining (will retry): ${error}`);
8539
+ cacheable = false;
8540
+ quarantine(meta, "directory_unavailable", `directory unavailable during evaluation: ${error}`);
8541
+ continue;
8542
+ }
8543
+ if (!decision.allowed) {
8544
+ quarantine(meta, decision.tier === "tier2" ? "tier2_denied" : "tier1_recheck_denied", decision.reason);
8545
+ continue;
8546
+ }
8547
+ // Accept the entry.
8548
+ currentDoc = afterDoc;
8549
+ if (isGenesis) {
8550
+ createdAt = meta.createdAt;
8551
+ decryptionKeyId = meta.decryptionKeyId;
8552
+ }
8553
+ acceptedMeta.push(meta);
8554
+ const parsed = (0, idGeneration_1.parseDocEntryId)(entry.id);
8555
+ if (parsed) {
8556
+ this.registerAutomergeHashMapping(docId, parsed.automergeHash, entry.id);
8557
+ }
8558
+ }
8559
+ if (currentDoc === null) {
8560
+ // The create was quarantined or absent: the document is logically absent.
8561
+ return { doc: null, cacheable };
8562
+ }
8563
+ const payload = currentDoc;
8564
+ const isDeleted = this.computeIsDeletedFromMetadata(acceptedMeta);
8565
+ const lastAccepted = acceptedMeta[acceptedMeta.length - 1];
8566
+ const lastModified = payload._lastModified || (lastAccepted ? lastAccepted.createdAt : Date.now());
8567
+ const witnessState = (0, storeEntryTime_1.metadataWitnessState)(acceptedMeta);
8568
+ return {
8569
+ doc: {
8570
+ id: docId,
8571
+ doc: currentDoc,
8572
+ createdAt: createdAt ?? lastModified,
8573
+ lastModified,
8574
+ decryptionKeyId,
8575
+ isDeleted,
8576
+ awaitingWitness: witnessState.awaitingWitness,
8577
+ witnessed: witnessState.witnessed,
8578
+ },
8579
+ cacheable,
8580
+ };
8581
+ }
5622
8582
  /**
5623
8583
  * Internal method to load a document from the content-addressed store
5624
8584
  */
@@ -5630,7 +8590,7 @@ class BaseMindooDB {
5630
8590
  // of whether L1/L2 still hold a cached copy. This keeps the public
5631
8591
  // contract of `loadDocumentInternal` aligned with `getDocument` /
5632
8592
  // `getAllDocumentIds`.
5633
- const indexEntryIdx = this.indexLookup.get(docId);
8593
+ const indexEntryIdx = this.getDocIndexPosition(docId);
5634
8594
  if (indexEntryIdx !== undefined && this.index[indexEntryIdx].accessState === "inaccessible") {
5635
8595
  return null;
5636
8596
  }
@@ -5717,6 +8677,36 @@ class BaseMindooDB {
5717
8677
  // Log all entry types
5718
8678
  const entryTypes = allEntryMetadata.map(em => `${em.entryType}@${em.createdAt}`).join(', ');
5719
8679
  this.logger.debug(`Entry types for ${docId}: ${entryTypes}`);
8680
+ // Access-control materialization (docs/accesscontrol.md §10): when the
8681
+ // tenant has access control active, materialize through the Tier 2
8682
+ // quarantine path (deterministic per-entry evaluation + cascade) instead of
8683
+ // the optimized batch path. Returns `undefined` when ACL is not enforced,
8684
+ // in which case we fall through to the normal fast path below.
8685
+ const aclResult = await this.maybeMaterializeWithAccessControl(docId, allEntryMetadata);
8686
+ if (aclResult !== undefined) {
8687
+ const aclDoc = aclResult.doc;
8688
+ // Only cache a fully-validated result. A transient validation failure
8689
+ // (audit finding #2) leaves `cacheable` false so the next load retries.
8690
+ if (aclDoc && aclResult.cacheable) {
8691
+ await this.storeCachedDocument(aclDoc);
8692
+ this.markDocDirty(docId);
8693
+ }
8694
+ this.performanceCallback?.onDocumentLoad?.({
8695
+ docId,
8696
+ cacheHit: false,
8697
+ metadataEntriesScanned: allEntryMetadata.length,
8698
+ replayEntriesLoaded: allEntryMetadata.length,
8699
+ snapshotUsed: false,
8700
+ cacheCheckTime,
8701
+ storeQueryTime,
8702
+ entryLoadTime,
8703
+ signatureVerificationTime,
8704
+ decryptionTime,
8705
+ automergeTime,
8706
+ totalTime: Date.now() - startedAt,
8707
+ });
8708
+ return aclDoc;
8709
+ }
5720
8710
  const metadataById = new Map(allEntryMetadata.map((meta) => [meta.id, meta]));
5721
8711
  const planStartedAt = Date.now();
5722
8712
  const materializationPlan = (0, MaterializationPlanner_1.computeDocumentMaterializationPlan)(docId, allEntryMetadata, {
@@ -5771,7 +8761,7 @@ class BaseMindooDB {
5771
8761
  // Verify signature against the encrypted snapshot (no decryption needed)
5772
8762
  // We sign the encrypted payload, so anyone can verify signatures without decryption keys
5773
8763
  const signatureStartedAt = Date.now();
5774
- isValid = await this.tenant.verifySignature(snapshotData.encryptedData, snapshotData.signature, snapshotData.createdByPublicKey);
8764
+ isValid = await this.tenant.verifyEntrySignature(snapshotData, snapshotData.encryptedData);
5775
8765
  signatureVerificationTime += Date.now() - signatureStartedAt;
5776
8766
  }
5777
8767
  if (!isValid) {
@@ -5793,11 +8783,24 @@ class BaseMindooDB {
5793
8783
  const automergeStartedAt = Date.now();
5794
8784
  doc = automerge_adapter_1.Automerge.load(decryptedSnapshot);
5795
8785
  automergeTime += Date.now() - automergeStartedAt;
5796
- this.logger.debug(`Successfully loaded snapshot, document heads: ${JSON.stringify(automerge_adapter_1.Automerge.getHeads(doc))}`);
5797
- // Register the snapshot's automerge hash -> entry ID mapping
5798
- const parsed = (0, idGeneration_1.parseDocEntryId)(snapshotData.id);
5799
- if (parsed) {
5800
- this.registerAutomergeHashMapping(docId, parsed.automergeHash, snapshotData.id);
8786
+ if (this.isDebugEnabled())
8787
+ this.logger.debug(`Successfully loaded snapshot, document heads: ${JSON.stringify(automerge_adapter_1.Automerge.getHeads(doc))}`);
8788
+ // Mandatory snapshot-head verification (docs/accesscontrol.md §10): the
8789
+ // decoded heads must exactly equal the declared snapshotHeadHashes, so
8790
+ // a snapshot cannot smuggle unauthorized content. On mismatch, discard
8791
+ // the snapshot and load from scratch via individually-verified entries.
8792
+ if (!(0, materializationGuard_1.snapshotHeadsMatch)(automerge_adapter_1.Automerge.getHeads(doc), snapshotData.snapshotHeadHashes)) {
8793
+ this.logger.warn(`Snapshot ${snapshotData.id} heads do not match declared snapshotHeadHashes, loading from scratch`);
8794
+ doc = undefined;
8795
+ startFromSnapshot = false;
8796
+ }
8797
+ else {
8798
+ // Register the snapshot's automerge hash -> entry ID mapping
8799
+ const parsed = (0, idGeneration_1.parseDocEntryId)(snapshotData.id);
8800
+ if (parsed) {
8801
+ this.registerAutomergeHashMapping(docId, parsed.automergeHash, snapshotData.id);
8802
+ }
8803
+ this.registerSnapshotHeadHashMappings(snapshotData);
5801
8804
  }
5802
8805
  }
5803
8806
  }
@@ -5806,7 +8809,8 @@ class BaseMindooDB {
5806
8809
  if (!doc) {
5807
8810
  this.logger.debug(`Initializing new Automerge document for ${docId}`);
5808
8811
  doc = automerge_adapter_1.Automerge.init();
5809
- this.logger.debug(`Initialized empty document, heads: ${JSON.stringify(automerge_adapter_1.Automerge.getHeads(doc))}`);
8812
+ if (this.isDebugEnabled())
8813
+ this.logger.debug(`Initialized empty document, heads: ${JSON.stringify(automerge_adapter_1.Automerge.getHeads(doc))}`);
5810
8814
  }
5811
8815
  // Load and apply all entries
5812
8816
  this.logger.debug(`Fetching ${entriesToLoad.length} entries from store for document ${docId}`);
@@ -5816,7 +8820,8 @@ class BaseMindooDB {
5816
8820
  this.logger.debug(`Retrieved ${entries.length} entries from store for document ${docId}`);
5817
8821
  this.logger.debug(`Loading document ${docId}: found ${entries.length} entries to apply (${startFromSnapshot ? 'starting from snapshot' : 'starting from scratch'})`);
5818
8822
  // Log current document state before applying entries
5819
- this.logger.debug(`Document state before applying entries: heads=${JSON.stringify(automerge_adapter_1.Automerge.getHeads(doc))}`);
8823
+ if (this.isDebugEnabled())
8824
+ this.logger.debug(`Document state before applying entries: heads=${JSON.stringify(automerge_adapter_1.Automerge.getHeads(doc))}`);
5820
8825
  // Filter entries for admin-only mode first
5821
8826
  const validEntries = entries.filter(entryData => {
5822
8827
  if (this._isAdminOnlyDb && entryData.createdByPublicKey !== this.getAdminPublicKey()) {
@@ -5859,7 +8864,7 @@ class BaseMindooDB {
5859
8864
  // Key was not trusted or failed to import
5860
8865
  return { entryData, isValid: false };
5861
8866
  }
5862
- const isValid = await this.verifySignatureWithKey(cryptoKey, entryData.encryptedData, entryData.signature);
8867
+ const isValid = await this.verifyEntrySignatureWithKey(cryptoKey, entryData);
5863
8868
  return { entryData, isValid };
5864
8869
  }));
5865
8870
  signatureVerificationTime += Date.now() - signatureStartedAt;
@@ -5904,7 +8909,8 @@ class BaseMindooDB {
5904
8909
  doc = result[0];
5905
8910
  automergeTime += Date.now() - automergeStartedAt;
5906
8911
  this.logger.debug(`Successfully applied ${changeBytes.length} changes to document ${docId}`);
5907
- this.logger.debug(`Document state after applying changes: heads=${JSON.stringify(automerge_adapter_1.Automerge.getHeads(doc))}`);
8912
+ if (this.isDebugEnabled())
8913
+ this.logger.debug(`Document state after applying changes: heads=${JSON.stringify(automerge_adapter_1.Automerge.getHeads(doc))}`);
5908
8914
  }
5909
8915
  catch (error) {
5910
8916
  this.logger.error(`Error applying changes to document ${docId}:`, error);
@@ -5916,7 +8922,8 @@ class BaseMindooDB {
5916
8922
  }
5917
8923
  // Extract metadata from document (doc is guaranteed to be defined at this point)
5918
8924
  this.logger.debug(`All entries applied successfully for document ${docId}`);
5919
- this.logger.debug(`Final document heads: ${JSON.stringify(automerge_adapter_1.Automerge.getHeads(doc))}`);
8925
+ if (this.isDebugEnabled())
8926
+ this.logger.debug(`Final document heads: ${JSON.stringify(automerge_adapter_1.Automerge.getHeads(doc))}`);
5920
8927
  const payload = doc;
5921
8928
  const isDeleted = this.computeIsDeletedFromMetadata(allEntryMetadata);
5922
8929
  this.logger.debug(`Document ${docId} isDeleted: ${isDeleted}`);
@@ -5932,6 +8939,7 @@ class BaseMindooDB {
5932
8939
  const firstEntry = allEntryMetadata.length > 0 ? allEntryMetadata[0] : null;
5933
8940
  const createdAt = firstEntry ? firstEntry.createdAt : lastModified;
5934
8941
  this.logger.debug(`Document ${docId} metadata: createdAt=${createdAt}, lastModified=${lastModified}, decryptionKeyId=${decryptionKeyId}`);
8942
+ const witnessState = (0, storeEntryTime_1.metadataWitnessState)(allEntryMetadata);
5935
8943
  const internalDoc = {
5936
8944
  id: docId,
5937
8945
  doc: doc, // doc is guaranteed to be defined at this point
@@ -5939,6 +8947,8 @@ class BaseMindooDB {
5939
8947
  lastModified,
5940
8948
  decryptionKeyId,
5941
8949
  isDeleted,
8950
+ awaitingWitness: witnessState.awaitingWitness,
8951
+ witnessed: witnessState.witnessed,
5942
8952
  };
5943
8953
  // Update cache
5944
8954
  await this.storeCachedDocument(internalDoc);
@@ -6037,11 +9047,21 @@ class BaseMindooDB {
6037
9047
  getLastModified: () => internalDoc.lastModified,
6038
9048
  getDecryptionKeyId: () => internalDoc.decryptionKeyId,
6039
9049
  isDeleted: () => internalDoc.isDeleted,
9050
+ isAccessible: () => internalDoc.accessible ?? true,
9051
+ isAwaitingWitness: () => internalDoc.awaitingWitness ?? false,
9052
+ isWitnessed: () => internalDoc.witnessed ?? false,
6040
9053
  getHeads: () => automerge_adapter_1.Automerge.getHeads(internalDoc.doc),
6041
9054
  getData: () => {
6042
- // Convert Automerge document to plain JS object, converting Text objects to strings
6043
- const jsDoc = this.convertAutomergeToJS(internalDoc.doc);
6044
- return createReadOnlyProxy(jsDoc);
9055
+ // Convert Automerge document to plain JS object, converting Text
9056
+ // objects to strings. Memoized per InternalDoc and invalidated via
9057
+ // the document's Automerge heads, so repeated getData() calls on an
9058
+ // unchanged doc skip the full re-materialization.
9059
+ const headsKey = automerge_adapter_1.Automerge.getHeads(internalDoc.doc).join(",");
9060
+ if (internalDoc.jsCache === undefined || internalDoc.jsCacheHeads !== headsKey) {
9061
+ internalDoc.jsCache = this.convertAutomergeToJS(internalDoc.doc);
9062
+ internalDoc.jsCacheHeads = headsKey;
9063
+ }
9064
+ return createReadOnlyProxy(internalDoc.jsCache);
6045
9065
  },
6046
9066
  // ========== Attachment Write Methods ==========
6047
9067
  // These throw errors in the read-only wrapper
@@ -6057,6 +9077,9 @@ class BaseMindooDB {
6057
9077
  appendToAttachment: async () => {
6058
9078
  throw new Error("appendToAttachment() can only be called within changeDoc() callback");
6059
9079
  },
9080
+ setAttachmentExtractedText: () => {
9081
+ throw new Error("setAttachmentExtractedText() can only be called within changeDoc() callback");
9082
+ },
6060
9083
  // ========== Attachment Read Methods ==========
6061
9084
  // These work in the read-only wrapper
6062
9085
  getAttachments: () => {
@@ -6076,6 +9099,50 @@ class BaseMindooDB {
6076
9099
  this.wrappedInternalDocs.set(wrapped, internalDoc);
6077
9100
  return wrapped;
6078
9101
  }
9102
+ /**
9103
+ * Build a lightweight read-only tombstone for a now-inaccessible index entry
9104
+ * (docs/accesscontrol.md §13): a document that was readable before but whose
9105
+ * decryption key the current KeyBag no longer holds (e.g. a revoked key).
9106
+ * Emitted by {@link iterateChangesSince} so incremental consumers get a
9107
+ * removal signal. Per the design rule it reports `isDeleted() === true`,
9108
+ * `isAccessible() === false`, `getData() === {}` (the plaintext is purged,
9109
+ * never materialized) and `getHeads() === []` — backed by an empty Automerge
9110
+ * document so it never touches the underlying ciphertext or the document cache.
9111
+ */
9112
+ buildInaccessibleDoc(entry) {
9113
+ const internalDoc = {
9114
+ id: entry.docId,
9115
+ doc: automerge_adapter_1.Automerge.init(),
9116
+ createdAt: 0,
9117
+ lastModified: entry.lastModified,
9118
+ decryptionKeyId: entry.decryptionKeyId,
9119
+ isDeleted: true,
9120
+ accessible: false,
9121
+ };
9122
+ return this.wrapDocument(internalDoc);
9123
+ }
9124
+ /**
9125
+ * Build a lightweight read-only tombstone for a deleted document, emitted
9126
+ * by {@link iterateChangesSince} instead of materializing the deleted
9127
+ * body. Reports `isDeleted() === true`, `isAccessible() === true` and
9128
+ * `getData() === {}` — the counterpart to {@link buildInaccessibleDoc} for
9129
+ * genuine deletions (distinguish the two via `isAccessible()`). Consumers
9130
+ * that need the last state of a deleted document can use
9131
+ * {@link iterateDocumentHistory} or {@link getDocumentAtTimestamp}.
9132
+ */
9133
+ buildDeletedDoc(entry) {
9134
+ const internalDoc = {
9135
+ id: entry.docId,
9136
+ doc: automerge_adapter_1.Automerge.init(),
9137
+ createdAt: 0,
9138
+ lastModified: entry.lastModified,
9139
+ decryptionKeyId: entry.decryptionKeyId,
9140
+ isDeleted: true,
9141
+ awaitingWitness: entry.awaitingWitness ?? false,
9142
+ witnessed: entry.witnessed ?? false,
9143
+ };
9144
+ return this.wrapDocument(internalDoc);
9145
+ }
6079
9146
  /**
6080
9147
  * Get an attachment reference by ID from a document's _attachments array.
6081
9148
  */
@@ -6118,7 +9185,7 @@ class BaseMindooDB {
6118
9185
  if (this._isAdminOnlyDb && chunk.createdByPublicKey !== this.getAdminPublicKey()) {
6119
9186
  throw new Error(`Admin-only DB: chunk ${chunk.id} not signed by admin key`);
6120
9187
  }
6121
- const isValid = await this.tenant.verifySignature(chunk.encryptedData, chunk.signature, chunk.createdByPublicKey);
9188
+ const isValid = await this.tenant.verifyEntrySignature(chunk, chunk.encryptedData);
6122
9189
  if (!isValid) {
6123
9190
  throw new Error(`Invalid signature for chunk ${chunk.id}`);
6124
9191
  }
@@ -6287,6 +9354,8 @@ class BaseMindooDB {
6287
9354
  encryptedSize: encryptedData.length,
6288
9355
  encryptedData,
6289
9356
  };
9357
+ // Bind the metadata with the author signature (audit finding #5).
9358
+ chunkEntry.metadataSignature = await this.computeEntryMetadataSignature(chunkEntry);
6290
9359
  chunks.push(chunkEntry);
6291
9360
  prevChunkId = chunkId;
6292
9361
  }
@@ -6331,22 +9400,25 @@ class BaseMindooDB {
6331
9400
  const encryptedData = new Uint8Array(0);
6332
9401
  const contentHash = await (0, idGeneration_1.computeContentHash)(encryptedData, this.getSubtle());
6333
9402
  const signature = await this.tenant.signPayload(encryptedData);
6334
- await this.putEntriesWithRetry(this.store, [{
6335
- entryType: "pending_attachment_upload",
6336
- id: this.pendingAttachmentUploadLedgerId(attachmentId),
6337
- contentHash,
6338
- docId,
6339
- dependencyIds: [],
6340
- createdAt,
6341
- attachmentId,
6342
- uploadStartedAt: Date.now(),
6343
- createdByPublicKey,
6344
- decryptionKeyId,
6345
- signature,
6346
- originalSize: 0,
6347
- encryptedSize: 0,
6348
- encryptedData,
6349
- }], `pending ledger ${attachmentId}`);
9403
+ const ledgerEntry = {
9404
+ entryType: "pending_attachment_upload",
9405
+ id: this.pendingAttachmentUploadLedgerId(attachmentId),
9406
+ contentHash,
9407
+ docId,
9408
+ dependencyIds: [],
9409
+ createdAt,
9410
+ attachmentId,
9411
+ uploadStartedAt: Date.now(),
9412
+ createdByPublicKey,
9413
+ decryptionKeyId,
9414
+ signature,
9415
+ originalSize: 0,
9416
+ encryptedSize: 0,
9417
+ encryptedData,
9418
+ };
9419
+ // Bind the metadata with the author signature (audit finding #5).
9420
+ ledgerEntry.metadataSignature = await this.computeEntryMetadataSignature(ledgerEntry);
9421
+ await this.putEntriesWithRetry(this.store, [ledgerEntry], `pending ledger ${attachmentId}`);
6350
9422
  }
6351
9423
  async clearPendingAttachmentUploadLedger(attachmentId) {
6352
9424
  const deleteEntriesById = this.store.deleteEntriesById;
@@ -6419,6 +9491,8 @@ class BaseMindooDB {
6419
9491
  encryptedSize: encryptedData.length,
6420
9492
  encryptedData,
6421
9493
  };
9494
+ // Bind the metadata with the author signature (audit finding #5).
9495
+ chunkEntry.metadataSignature = await this.computeEntryMetadataSignature(chunkEntry);
6422
9496
  pendingEntries.push(chunkEntry);
6423
9497
  pendingEncryptedBytes += encryptedData.length;
6424
9498
  chunkCount++;
@@ -6513,6 +9587,8 @@ class BaseMindooDB {
6513
9587
  encryptedSize: encryptedData.length,
6514
9588
  encryptedData,
6515
9589
  };
9590
+ // Bind the metadata with the author signature (audit finding #5).
9591
+ chunkEntry.metadataSignature = await this.computeEntryMetadataSignature(chunkEntry);
6516
9592
  chunks.push(chunkEntry);
6517
9593
  prevChunkId = chunkId;
6518
9594
  }
@@ -6645,6 +9721,16 @@ class BaseMindooDB {
6645
9721
  }
6646
9722
  if (storeKind === types_2.StoreKind.docs) {
6647
9723
  await this.syncStoreChanges();
9724
+ // SDK-driven key-distribution reconcile (docs/accesscontrol.md §13).
9725
+ // After the directory pulls and processes new entries, reconcile the
9726
+ // local KeyBag against the directory head: bulk-remove revoked keys
9727
+ // (forgetting now-inaccessible docs) and import keys newly pushed to
9728
+ // this user (revealing their docs on the next content-DB pull). Runs in
9729
+ // the SDK so standalone apps get it too, not just Haven. Best-effort and
9730
+ // single-flight; never blocks or fails the sync.
9731
+ if (this.store.getId() === "directory") {
9732
+ await this.tenant.reconcileKeyDistributionsForCurrentUserSafe();
9733
+ }
6648
9734
  }
6649
9735
  if (options?.signal?.aborted) {
6650
9736
  this.logger.info(`Pull cancelled after local processing for ${storeKind} entries`);
@@ -6731,4 +9817,11 @@ class BaseMindooDB {
6731
9817
  }
6732
9818
  }
6733
9819
  exports.BaseMindooDB = BaseMindooDB;
9820
+ /** The "access control not enforced" allow decision returned by the
9821
+ * non-throwing `canCreate`/`canChange`/`canDelete` prediction helpers. */
9822
+ BaseMindooDB.ACL_NOT_ENFORCED_DECISION = {
9823
+ allowed: true,
9824
+ reason: "access control not enforced",
9825
+ tier: "tier1",
9826
+ };
6734
9827
  //# sourceMappingURL=BaseMindooDB.js.map