@noy-db/hub 0.2.0-pre.4 → 0.2.0-pre.5

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 (280) hide show
  1. package/dist/aggregate/index.cjs.map +1 -1
  2. package/dist/aggregate/index.js +4 -4
  3. package/dist/attestation/index.cjs.map +1 -1
  4. package/dist/attestation/index.d.cts +4 -4
  5. package/dist/attestation/index.d.ts +4 -4
  6. package/dist/attestation/index.js +6 -6
  7. package/dist/blobs/index.cjs.map +1 -1
  8. package/dist/blobs/index.d.cts +5 -5
  9. package/dist/blobs/index.d.ts +5 -5
  10. package/dist/blobs/index.js +5 -5
  11. package/dist/bundle/index.cjs +443 -338
  12. package/dist/bundle/index.cjs.map +1 -1
  13. package/dist/bundle/index.d.cts +17 -17
  14. package/dist/bundle/index.d.ts +17 -17
  15. package/dist/bundle/index.js +10 -10
  16. package/dist/bundle/index.js.map +1 -1
  17. package/dist/{chunk-YL2DR3HY.js → chunk-25WFLKOH.js} +2 -2
  18. package/dist/chunk-25WFLKOH.js.map +1 -0
  19. package/dist/{chunk-EMEX37ZN.js → chunk-2GMRNNI3.js} +3 -3
  20. package/dist/chunk-2GMRNNI3.js.map +1 -0
  21. package/dist/{chunk-NGSPBLLE.js → chunk-34XGYMQT.js} +3 -3
  22. package/dist/chunk-34XGYMQT.js.map +1 -0
  23. package/dist/{chunk-FXQYZNOW.js → chunk-5OVIFUQE.js} +1 -1
  24. package/dist/chunk-5OVIFUQE.js.map +1 -0
  25. package/dist/{chunk-P6256WTJ.js → chunk-5QPF2MJ5.js} +3 -3
  26. package/dist/chunk-5QPF2MJ5.js.map +1 -0
  27. package/dist/{chunk-5ZGZ6HIZ.js → chunk-5VMTAX4Y.js} +2 -2
  28. package/dist/{chunk-74JEQFMT.js → chunk-6A4AMQ2H.js} +5 -5
  29. package/dist/chunk-6A4AMQ2H.js.map +1 -0
  30. package/dist/{chunk-YDLAFP36.js → chunk-6HJ2ZALB.js} +1 -1
  31. package/dist/chunk-6HJ2ZALB.js.map +1 -0
  32. package/dist/{chunk-GDTCGIPX.js → chunk-7TX7HN42.js} +2 -2
  33. package/dist/chunk-7TX7HN42.js.map +1 -0
  34. package/dist/{chunk-EPK6A3WJ.js → chunk-A3JMGXPG.js} +2 -2
  35. package/dist/chunk-A3JMGXPG.js.map +1 -0
  36. package/dist/{chunk-75QDHSE4.js → chunk-A4JNVBPF.js} +5 -5
  37. package/dist/{chunk-IS5HWQO7.js → chunk-ARZAHCCF.js} +3 -3
  38. package/dist/{chunk-T6HQMVML.js → chunk-BT7544RM.js} +399 -301
  39. package/dist/chunk-BT7544RM.js.map +1 -0
  40. package/dist/{chunk-4OQWR46B.js → chunk-CCC25PA7.js} +5 -5
  41. package/dist/{chunk-NSLTPGEN.js → chunk-CGJFCT3X.js} +2 -2
  42. package/dist/{chunk-YK72A4IT.js → chunk-CKH247ZR.js} +4 -4
  43. package/dist/{chunk-HGZ7DC5H.js → chunk-DFCINPB5.js} +2 -2
  44. package/dist/chunk-DFCINPB5.js.map +1 -0
  45. package/dist/{chunk-4X2S7PBF.js → chunk-E225X5CQ.js} +3 -3
  46. package/dist/chunk-E225X5CQ.js.map +1 -0
  47. package/dist/{chunk-5YHWBPOT.js → chunk-ED3E3OLO.js} +2 -2
  48. package/dist/{chunk-UOF74WQY.js → chunk-EKTOYEZ3.js} +2 -2
  49. package/dist/{chunk-SAVQ6E2O.js → chunk-G26QAQNI.js} +2 -2
  50. package/dist/{chunk-YMYK7US4.js → chunk-HIELMTUK.js} +2 -2
  51. package/dist/{chunk-MRIBLZL3.js → chunk-ICH4AIGL.js} +1 -1
  52. package/dist/chunk-ICH4AIGL.js.map +1 -0
  53. package/dist/{chunk-LOL725S4.js → chunk-JSYTGEX4.js} +3 -3
  54. package/dist/{chunk-FBMXWVGP.js → chunk-KGFV72WK.js} +5 -5
  55. package/dist/{chunk-GVXBHCZ2.js → chunk-LJO6Q3X6.js} +5 -5
  56. package/dist/chunk-LJO6Q3X6.js.map +1 -0
  57. package/dist/{chunk-ZC2AAE6J.js → chunk-LWFQYT4N.js} +2 -2
  58. package/dist/chunk-LWFQYT4N.js.map +1 -0
  59. package/dist/{chunk-K5PVGKE4.js → chunk-MDIC4FAU.js} +2 -2
  60. package/dist/{chunk-A6SWRXUQ.js → chunk-NONMIU6C.js} +2 -2
  61. package/dist/{chunk-ZUMGGHRB.js → chunk-OPD3PZOG.js} +4 -4
  62. package/dist/{chunk-LS3JLEIB.js → chunk-PS5G6A3Y.js} +4 -4
  63. package/dist/{chunk-KYKMKLJ6.js → chunk-PX3MJ6RB.js} +3 -3
  64. package/dist/{chunk-FCDO7UAO.js → chunk-R4LTCI6O.js} +2 -2
  65. package/dist/{chunk-BFI3RS42.js → chunk-R7JTYCRX.js} +2 -2
  66. package/dist/chunk-R7JTYCRX.js.map +1 -0
  67. package/dist/{chunk-WRLHNG6H.js → chunk-RIHZBSWJ.js} +4 -4
  68. package/dist/chunk-RIHZBSWJ.js.map +1 -0
  69. package/dist/{chunk-UVPGJXVO.js → chunk-SGSHQ4PH.js} +5 -5
  70. package/dist/{chunk-TLFUDXVV.js → chunk-T6MTNGBM.js} +5 -5
  71. package/dist/chunk-T6MTNGBM.js.map +1 -0
  72. package/dist/{chunk-6S3LLAQ5.js → chunk-TNBIWSQ7.js} +2 -2
  73. package/dist/{chunk-GD3BGKAR.js → chunk-UGVDIOY7.js} +2 -2
  74. package/dist/{chunk-FS7A4XNF.js → chunk-WEA4TDTJ.js} +3 -3
  75. package/dist/{chunk-4UBOTYP5.js → chunk-XDW37COG.js} +5 -5
  76. package/dist/chunk-XDW37COG.js.map +1 -0
  77. package/dist/{chunk-QAU5HM6Q.js → chunk-XVJFFGTG.js} +3 -3
  78. package/dist/{chunk-2EYC3WDT.js → chunk-Y3P5DEMZ.js} +6 -6
  79. package/dist/chunk-Y3P5DEMZ.js.map +1 -0
  80. package/dist/{chunk-G7PAZ3TD.js → chunk-YEHUEUNP.js} +4 -4
  81. package/dist/chunk-YEHUEUNP.js.map +1 -0
  82. package/dist/{chunk-2XLVPKXG.js → chunk-YJ46RFCD.js} +2 -2
  83. package/dist/{chunk-KMI2NBBF.js → chunk-YZ6JETII.js} +6 -6
  84. package/dist/{chunk-NCO2JGKK.js → chunk-Z6FNBOTC.js} +1 -1
  85. package/dist/chunk-Z6FNBOTC.js.map +1 -0
  86. package/dist/{chunk-GAUBWHAF.js → chunk-ZQMYB56Z.js} +4 -4
  87. package/dist/consent/index.cjs.map +1 -1
  88. package/dist/consent/index.d.cts +5 -5
  89. package/dist/consent/index.d.ts +5 -5
  90. package/dist/consent/index.js +3 -3
  91. package/dist/{crypto-H2Y3DDFW.js → crypto-5UDZZL26.js} +3 -3
  92. package/dist/{delegation-QSC7G5QC.js → delegation-42LO4WFO.js} +5 -5
  93. package/dist/derivations/index.cjs +1 -1
  94. package/dist/derivations/index.cjs.map +1 -1
  95. package/dist/derivations/index.d.cts +8 -8
  96. package/dist/derivations/index.d.ts +8 -8
  97. package/dist/derivations/index.js +4 -4
  98. package/dist/{dev-unlock-Cf2B7Kih.d.ts → dev-unlock--ahUTrhc.d.ts} +1 -1
  99. package/dist/{dev-unlock-De3mjQWv.d.cts → dev-unlock-BIwt2V3p.d.cts} +1 -1
  100. package/dist/executor-AWCHQ2KN.js +8 -0
  101. package/dist/executor-RWICJI7J.js +11 -0
  102. package/dist/executor-SOLEQVUB.js +8 -0
  103. package/dist/{fanout-sidecar-NRBWSLRK.js → fanout-sidecar-EVICRM46.js} +2 -2
  104. package/dist/fanout-sidecar-EVICRM46.js.map +1 -0
  105. package/dist/guards/index.cjs +1 -1
  106. package/dist/guards/index.cjs.map +1 -1
  107. package/dist/guards/index.d.cts +6 -6
  108. package/dist/guards/index.d.ts +6 -6
  109. package/dist/guards/index.js +4 -4
  110. package/dist/{hash-vBCB0-Ps.d.cts → hash-BQVrGV-t.d.cts} +1 -1
  111. package/dist/{hash-gVn_uKhp.d.ts → hash-CJEFQxSD.d.ts} +1 -1
  112. package/dist/history/index.cjs.map +1 -1
  113. package/dist/history/index.d.cts +6 -6
  114. package/dist/history/index.d.ts +6 -6
  115. package/dist/history/index.js +6 -6
  116. package/dist/i18n/index.cjs.map +1 -1
  117. package/dist/i18n/index.d.cts +5 -5
  118. package/dist/i18n/index.d.ts +5 -5
  119. package/dist/i18n/index.js +7 -7
  120. package/dist/{index-DVkvrgpm.d.cts → index-5I0MZ0jQ.d.cts} +12 -12
  121. package/dist/{index-BF1B2HB9.d.ts → index-fIPPh5dg.d.ts} +12 -12
  122. package/dist/index.cjs +362 -264
  123. package/dist/index.cjs.map +1 -1
  124. package/dist/index.d.cts +20 -22
  125. package/dist/index.d.ts +20 -22
  126. package/dist/index.js +45 -45
  127. package/dist/index.js.map +1 -1
  128. package/dist/indexing/index.cjs +1 -1
  129. package/dist/indexing/index.cjs.map +1 -1
  130. package/dist/indexing/index.d.cts +3 -3
  131. package/dist/indexing/index.d.ts +3 -3
  132. package/dist/indexing/index.js +4 -4
  133. package/dist/issue-IODMTPME.js +12 -0
  134. package/dist/{lazy-builder-Rpd-V3jP.d.ts → lazy-builder-D1MyR1qH.d.ts} +2 -2
  135. package/dist/{lazy-builder-C-rPfWG0.d.cts → lazy-builder-DXlSCNCJ.d.cts} +2 -2
  136. package/dist/{ledger-WOEJUYTP.js → ledger-UX4QIHWI.js} +6 -6
  137. package/dist/materialized-views/index.cjs.map +1 -1
  138. package/dist/materialized-views/index.d.cts +18 -18
  139. package/dist/materialized-views/index.d.ts +18 -18
  140. package/dist/materialized-views/index.js +7 -7
  141. package/dist/noydb-6TADQIYH.js +34 -0
  142. package/dist/overlay-views/index.cjs +1 -1
  143. package/dist/overlay-views/index.cjs.map +1 -1
  144. package/dist/overlay-views/index.d.cts +8 -8
  145. package/dist/overlay-views/index.d.ts +8 -8
  146. package/dist/overlay-views/index.js +4 -4
  147. package/dist/periods/index.cjs.map +1 -1
  148. package/dist/periods/index.d.cts +5 -5
  149. package/dist/periods/index.d.ts +5 -5
  150. package/dist/periods/index.js +6 -6
  151. package/dist/{predicate-Dnu81tsS.d.cts → predicate-B0IKeBXx.d.cts} +1 -1
  152. package/dist/{predicate-Dnu81tsS.d.ts → predicate-B0IKeBXx.d.ts} +1 -1
  153. package/dist/{public-envelope-OHQ5UZFM.js → public-envelope-YKHKP74C.js} +4 -4
  154. package/dist/query/index.cjs +2 -2
  155. package/dist/query/index.cjs.map +1 -1
  156. package/dist/query/index.d.cts +2 -2
  157. package/dist/query/index.d.ts +2 -2
  158. package/dist/query/index.js +6 -6
  159. package/dist/registry-446I2NMN.js +8 -0
  160. package/dist/{registry-CDHASH73.js → registry-4NEW7LQY.js} +3 -3
  161. package/dist/registry-524KJZG4.js +8 -0
  162. package/dist/registry-DKEXOJVO.js +7 -0
  163. package/dist/{revoke-7JOVLZFD.js → revoke-R5NIQ74J.js} +6 -6
  164. package/dist/session/index.cjs.map +1 -1
  165. package/dist/session/index.d.cts +6 -6
  166. package/dist/session/index.d.ts +6 -6
  167. package/dist/session/index.js +3 -3
  168. package/dist/shadow/index.cjs.map +1 -1
  169. package/dist/shadow/index.d.cts +5 -5
  170. package/dist/shadow/index.d.ts +5 -5
  171. package/dist/shadow/index.js +2 -2
  172. package/dist/{signer-M4K5HBLD.js → signer-WGDJNWSU.js} +5 -5
  173. package/dist/{stale-PAGCS4K5.js → stale-74WGLVZ2.js} +2 -2
  174. package/dist/store/index.cjs.map +1 -1
  175. package/dist/store/index.d.cts +5 -5
  176. package/dist/store/index.d.ts +5 -5
  177. package/dist/store/index.js +2 -2
  178. package/dist/sync/index.cjs.map +1 -1
  179. package/dist/sync/index.d.cts +4 -4
  180. package/dist/sync/index.d.ts +4 -4
  181. package/dist/sync/index.js +4 -4
  182. package/dist/team/index.cjs +1 -1
  183. package/dist/team/index.cjs.map +1 -1
  184. package/dist/team/index.d.cts +5 -5
  185. package/dist/team/index.d.ts +5 -5
  186. package/dist/team/index.js +8 -8
  187. package/dist/tx/index.cjs +2 -2
  188. package/dist/tx/index.cjs.map +1 -1
  189. package/dist/tx/index.d.cts +5 -5
  190. package/dist/tx/index.d.ts +5 -5
  191. package/dist/tx/index.js +3 -3
  192. package/dist/tx/index.js.map +1 -1
  193. package/dist/{types-D9eB0Rvh.d.ts → types-BV4AZKmx.d.ts} +340 -302
  194. package/dist/{types-CSLcfytP.d.cts → types-BeKi0hCx.d.cts} +340 -302
  195. package/dist/{ulid-CiM2OAeM.d.ts → ulid-CQc0eBxE.d.ts} +19 -19
  196. package/dist/{ulid-CG2YvAbg.d.cts → ulid-Cvljl7ZZ.d.cts} +19 -19
  197. package/dist/util/index.cjs.map +1 -1
  198. package/dist/util/index.js +1 -1
  199. package/dist/{with-derivation-Bzpj6UTv.d.ts → with-derivation-BWcwmevt.d.ts} +1 -1
  200. package/dist/{with-derivation-DWajFh4K.d.cts → with-derivation-BkOBDhsu.d.cts} +1 -1
  201. package/dist/{with-guard-DF_Ul3DT.d.cts → with-guard-BD4Hyu8s.d.cts} +1 -1
  202. package/dist/{with-guard-DR7U-l4v.d.ts → with-guard-Du54s3Ti.d.ts} +1 -1
  203. package/dist/{with-materialized-view-qtoJ3xKJ.d.ts → with-materialized-view-B5W4wFAC.d.ts} +2 -2
  204. package/dist/{with-materialized-view-_piodoIz.d.cts → with-materialized-view-BCPPZdjC.d.cts} +2 -2
  205. package/dist/{with-overlayed-view-DFaRfgMr.d.ts → with-overlayed-view-B8RrlLsG.d.cts} +2 -2
  206. package/dist/{with-overlayed-view-DwzCKxn2.d.cts → with-overlayed-view-Cw-h9p9N.d.ts} +2 -2
  207. package/package.json +3 -3
  208. package/dist/chunk-2EYC3WDT.js.map +0 -1
  209. package/dist/chunk-4UBOTYP5.js.map +0 -1
  210. package/dist/chunk-4X2S7PBF.js.map +0 -1
  211. package/dist/chunk-74JEQFMT.js.map +0 -1
  212. package/dist/chunk-BFI3RS42.js.map +0 -1
  213. package/dist/chunk-EMEX37ZN.js.map +0 -1
  214. package/dist/chunk-EPK6A3WJ.js.map +0 -1
  215. package/dist/chunk-FXQYZNOW.js.map +0 -1
  216. package/dist/chunk-G7PAZ3TD.js.map +0 -1
  217. package/dist/chunk-GDTCGIPX.js.map +0 -1
  218. package/dist/chunk-GVXBHCZ2.js.map +0 -1
  219. package/dist/chunk-HGZ7DC5H.js.map +0 -1
  220. package/dist/chunk-MRIBLZL3.js.map +0 -1
  221. package/dist/chunk-NCO2JGKK.js.map +0 -1
  222. package/dist/chunk-NGSPBLLE.js.map +0 -1
  223. package/dist/chunk-P6256WTJ.js.map +0 -1
  224. package/dist/chunk-T6HQMVML.js.map +0 -1
  225. package/dist/chunk-TLFUDXVV.js.map +0 -1
  226. package/dist/chunk-WRLHNG6H.js.map +0 -1
  227. package/dist/chunk-YDLAFP36.js.map +0 -1
  228. package/dist/chunk-YL2DR3HY.js.map +0 -1
  229. package/dist/chunk-ZC2AAE6J.js.map +0 -1
  230. package/dist/executor-BZKFZVRC.js +0 -8
  231. package/dist/executor-GFZFDQXV.js +0 -8
  232. package/dist/executor-KT2IOZVP.js +0 -11
  233. package/dist/fanout-sidecar-NRBWSLRK.js.map +0 -1
  234. package/dist/issue-BAJ7ZB4S.js +0 -12
  235. package/dist/noydb-XNQSKXGO.js +0 -34
  236. package/dist/registry-2IEARCGT.js +0 -7
  237. package/dist/registry-EMGLZGR6.js +0 -8
  238. package/dist/registry-NQALYR77.js +0 -8
  239. /package/dist/{chunk-5ZGZ6HIZ.js.map → chunk-5VMTAX4Y.js.map} +0 -0
  240. /package/dist/{chunk-75QDHSE4.js.map → chunk-A4JNVBPF.js.map} +0 -0
  241. /package/dist/{chunk-IS5HWQO7.js.map → chunk-ARZAHCCF.js.map} +0 -0
  242. /package/dist/{chunk-4OQWR46B.js.map → chunk-CCC25PA7.js.map} +0 -0
  243. /package/dist/{chunk-NSLTPGEN.js.map → chunk-CGJFCT3X.js.map} +0 -0
  244. /package/dist/{chunk-YK72A4IT.js.map → chunk-CKH247ZR.js.map} +0 -0
  245. /package/dist/{chunk-5YHWBPOT.js.map → chunk-ED3E3OLO.js.map} +0 -0
  246. /package/dist/{chunk-UOF74WQY.js.map → chunk-EKTOYEZ3.js.map} +0 -0
  247. /package/dist/{chunk-SAVQ6E2O.js.map → chunk-G26QAQNI.js.map} +0 -0
  248. /package/dist/{chunk-YMYK7US4.js.map → chunk-HIELMTUK.js.map} +0 -0
  249. /package/dist/{chunk-LOL725S4.js.map → chunk-JSYTGEX4.js.map} +0 -0
  250. /package/dist/{chunk-FBMXWVGP.js.map → chunk-KGFV72WK.js.map} +0 -0
  251. /package/dist/{chunk-K5PVGKE4.js.map → chunk-MDIC4FAU.js.map} +0 -0
  252. /package/dist/{chunk-A6SWRXUQ.js.map → chunk-NONMIU6C.js.map} +0 -0
  253. /package/dist/{chunk-ZUMGGHRB.js.map → chunk-OPD3PZOG.js.map} +0 -0
  254. /package/dist/{chunk-LS3JLEIB.js.map → chunk-PS5G6A3Y.js.map} +0 -0
  255. /package/dist/{chunk-KYKMKLJ6.js.map → chunk-PX3MJ6RB.js.map} +0 -0
  256. /package/dist/{chunk-FCDO7UAO.js.map → chunk-R4LTCI6O.js.map} +0 -0
  257. /package/dist/{chunk-UVPGJXVO.js.map → chunk-SGSHQ4PH.js.map} +0 -0
  258. /package/dist/{chunk-6S3LLAQ5.js.map → chunk-TNBIWSQ7.js.map} +0 -0
  259. /package/dist/{chunk-GD3BGKAR.js.map → chunk-UGVDIOY7.js.map} +0 -0
  260. /package/dist/{chunk-FS7A4XNF.js.map → chunk-WEA4TDTJ.js.map} +0 -0
  261. /package/dist/{chunk-QAU5HM6Q.js.map → chunk-XVJFFGTG.js.map} +0 -0
  262. /package/dist/{chunk-2XLVPKXG.js.map → chunk-YJ46RFCD.js.map} +0 -0
  263. /package/dist/{chunk-KMI2NBBF.js.map → chunk-YZ6JETII.js.map} +0 -0
  264. /package/dist/{chunk-GAUBWHAF.js.map → chunk-ZQMYB56Z.js.map} +0 -0
  265. /package/dist/{crypto-H2Y3DDFW.js.map → crypto-5UDZZL26.js.map} +0 -0
  266. /package/dist/{delegation-QSC7G5QC.js.map → delegation-42LO4WFO.js.map} +0 -0
  267. /package/dist/{executor-BZKFZVRC.js.map → executor-AWCHQ2KN.js.map} +0 -0
  268. /package/dist/{executor-GFZFDQXV.js.map → executor-RWICJI7J.js.map} +0 -0
  269. /package/dist/{executor-KT2IOZVP.js.map → executor-SOLEQVUB.js.map} +0 -0
  270. /package/dist/{issue-BAJ7ZB4S.js.map → issue-IODMTPME.js.map} +0 -0
  271. /package/dist/{ledger-WOEJUYTP.js.map → ledger-UX4QIHWI.js.map} +0 -0
  272. /package/dist/{noydb-XNQSKXGO.js.map → noydb-6TADQIYH.js.map} +0 -0
  273. /package/dist/{public-envelope-OHQ5UZFM.js.map → public-envelope-YKHKP74C.js.map} +0 -0
  274. /package/dist/{registry-2IEARCGT.js.map → registry-446I2NMN.js.map} +0 -0
  275. /package/dist/{registry-CDHASH73.js.map → registry-4NEW7LQY.js.map} +0 -0
  276. /package/dist/{registry-EMGLZGR6.js.map → registry-524KJZG4.js.map} +0 -0
  277. /package/dist/{registry-NQALYR77.js.map → registry-DKEXOJVO.js.map} +0 -0
  278. /package/dist/{revoke-7JOVLZFD.js.map → revoke-R5NIQ74J.js.map} +0 -0
  279. /package/dist/{signer-M4K5HBLD.js.map → signer-WGDJNWSU.js.map} +0 -0
  280. /package/dist/{stale-PAGCS4K5.js.map → stale-74WGLVZ2.js.map} +0 -0
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../src/query/join.ts","../src/query/live.ts","../src/aggregate/strategy.ts","../src/query/builder.ts","../src/query/scan-builder.ts"],"sourcesContent":["/**\n * Query DSL `.join()` — eager, single-FK, intra-vault joins.\n *\n * resolves a ref()-declared foreign key into an attached\n * right-side record under an alias, using one of two planner paths\n * selected automatically:\n *\n * - **nested-loop** — right-side source exposes `lookupById`, so\n * each left row costs O(1). This is the common path for joins\n * against a Collection, which backs `lookupById` with a Map\n * lookup.\n * - **hash** — right-side has only `snapshot()`. Build a\n * `Map<id, record>` once, probe per left row. Same asymptotic\n * cost for our collections, but the path exists as a fallback\n * for custom QuerySource implementations and as an explicit\n * test-only override via `{ strategy: 'hash' }`.\n *\n * Scope:\n *\n * - Equi-joins on declared `ref()` fields only. Joins on\n * undeclared fields throw at plan time with an actionable error\n * naming the field and collection.\n * - Same-vault only. Cross-vault correlation goes\n * through `queryAcross`; this is an architectural\n * invariant, not a limitation we plan to lift.\n * - Hard row ceiling via `JoinTooLargeError` — default 50k per\n * side, override via `{ maxRows }`. Warns at 80% of the ceiling\n * on the existing warn channel.\n * - Three ref-mode behaviors on dangling refs:\n * strict → `DanglingReferenceError`,\n * warn → attach `null` with a one-shot warning,\n * cascade → attach `null` silently (cascade is a delete-time\n * mode; any dangling refs still present at read time are\n * mid-flight cascades or orphans from earlier, not a DSL error).\n *\n * Partition-awareness seam:\n *\n * Every `JoinLeg` carries a `partitionScope` field that is always\n * `'all'` in. The executor never reads this field.\n * partition-aware joins will start populating it from `where()`\n * predicates on the partition key without changing the planner's\n * external shape — this is the whole reason it exists now.\n *\n * Joins stay OUT of the ledger: reads don't touch `_ledger/`,\n * including joined reads.\n */\n\nimport type { RefDescriptor, RefMode } from '../refs.js'\nimport { readPath } from './predicate.js'\nimport { JoinTooLargeError, DanglingReferenceError } from '../errors.js'\n\n/** Planner strategy for a single join leg. Auto-selected unless overridden. */\nexport type JoinStrategy = 'hash' | 'nested'\n\n/** Default per-side row ceiling before `.join()` throws `JoinTooLargeError`. */\nexport const DEFAULT_JOIN_MAX_ROWS = 50_000\n\n/**\n * Fraction of the row ceiling at which a one-shot warning is emitted.\n * At 80% we warn; at 100% we throw. The warn gives consumers a\n * heads-up before the hard error so they can raise the ceiling or\n * filter further without first hitting a broken query.\n */\nconst JOIN_WARN_FRACTION = 0.8\n\n/**\n * Internal representation of a single join leg in the query plan.\n *\n * This is the primary place where constraint #1 is honored:\n * every leg carries a `partitionScope` field that is always `'all'`\n * in and is never read by the executor. partition-aware\n * joins will start populating it from `where()` predicates on the\n * partition key without changing the planner's external shape.\n */\nexport interface JoinLeg {\n /** Field on the left-side record holding the foreign key value. */\n readonly field: string\n /** Alias key under which the joined right-side record attaches. */\n readonly as: string\n /** Target collection name, resolved from the `ref()` declaration. */\n readonly target: string\n /** Ref mode controlling behavior on dangling refs at read time. */\n readonly mode: RefMode\n /** Manual planner strategy override. `undefined` → auto-select. */\n readonly strategy: JoinStrategy | undefined\n /** Per-side row ceiling override. `undefined` → DEFAULT_JOIN_MAX_ROWS. */\n readonly maxRows: number | undefined\n /**\n * Partition scope for future partition-aware joins. Always `'all'`\n * today — the executor never reads this field. Future versions will\n * populate it from `where()` predicates without breaking the\n * planner's external shape. Do not remove even though it looks\n * unused today — that's the whole point of having it.\n */\n readonly partitionScope: 'all' | readonly string[]\n /**\n * When `true`, this is a dictionary join. The executor\n * resolves the left-field value against the dict snapshot and\n * attaches `{ ...labels, key }` rather than a right-side record.\n * `target` holds the dictionary name (not a collection name).\n */\n readonly isDictJoin?: true\n}\n\n/**\n * Minimal shape of a joinable right-side record source.\n *\n * Collections implement this structurally via their `QuerySource`;\n * sources without `lookupById` force the hash-join fallback. Kept as\n * a thin interface so tests can wire up plain-object sources without\n * pulling in the full Collection class.\n *\n * The optional `subscribe` is used by `Query.live()` to merge\n * right-side change streams into the live re-run trigger. Sources\n * that omit `subscribe` still work for live joins — they just\n * don't drive re-fires when their right side mutates. Collection\n * implements `subscribe` by hooking into the existing per-\n * vault event emitter.\n */\nexport interface JoinableSource {\n snapshot(): readonly unknown[]\n lookupById?(id: string): unknown\n /**\n * Subscribe to mutations on this source. The callback fires\n * AFTER the underlying record set has been updated. Returns an\n * unsubscribe function. Optional — sources without this method\n * cannot trigger live-join re-fires from their side.\n */\n subscribe?(cb: () => void): () => void\n}\n\n/**\n * Join resolution context attached to a `Query` when it's constructed\n * from a `Collection`. Holds everything the `.join()` method needs to\n * translate a field name into a target collection + ref mode, and\n * everything the executor needs to read the right side.\n *\n * Kept as a structural interface so `Vault` can implement it\n * without `Query` needing to import `Vault` (circular-import\n * avoid). The Collection wires this up in its `query()` method using\n * the `joinResolver` back-reference the Vault passes in.\n */\nexport interface JoinContext {\n /** Name of the left-side (owning) collection. */\n readonly leftCollection: string\n /** Look up a `RefDescriptor` by field name on the left collection. */\n resolveRef(field: string): RefDescriptor | null\n /** Resolve a right-side source by target collection name. */\n resolveSource(collectionName: string): JoinableSource | null\n /**\n * Resolve a dictKey join source. Returns a `JoinableSource`\n * whose snapshot exposes `{ key, ...labels }` records, keyed by the\n * stable dictionary key. `null` when the field is not a dictKey.\n *\n * The source is built from the compartment's in-memory dictionary\n * snapshot — same data as `DictionaryHandle.list()`, O(1) per lookup.\n */\n resolveDictSource?(field: string): JoinableSource | null\n}\n\n/**\n * Coerce an unknown FK value into a lookup key string.\n *\n * Legitimate ref values are strings or numbers — the same narrowing\n * the write-time `enforceRefsOnPut` path applies. Anything else\n * (objects, arrays, booleans, null, undefined) is treated as \"no\n * ref\" and returns `null`, so the join attaches `null` instead of\n * running `String({})` and producing `'[object Object]'` as a\n * bucket key. This matches the lint rule guidance and keeps\n * bizarre FK values from producing silently-wrong lookups.\n */\nfunction coerceRefKey(value: unknown): string | null {\n if (value === null || value === undefined) return null\n if (typeof value === 'string') return value\n if (typeof value === 'number' || typeof value === 'bigint') return String(value)\n return null\n}\n\n/**\n * Warn-channel deduplication for dangling-ref `'warn'` mode. Keyed\n * by `field → target:refId` so the same dangling ref only produces\n * one warning even across many rows or repeated queries.\n */\nconst warnedDanglingKeys = new Set<string>()\nfunction warnOnceDangling(field: string, target: string, refId: string): void {\n const key = `${field}→${target}:${refId}`\n if (warnedDanglingKeys.has(key)) return\n warnedDanglingKeys.add(key)\n console.warn(\n `[noy-db] .join() encountered dangling ref in 'warn' mode: ` +\n `field \"${field}\" → \"${target}:${refId}\" not found. Attaching null.`,\n )\n}\n\n/**\n * Track row-ceiling warnings to fire only once per (target, side).\n * Prevents per-query spam when a consumer is running the same query\n * repeatedly (e.g. in a reactive loop).\n */\nconst warnedCeilingKeys = new Set<string>()\nfunction warnCeilingApproaching(\n target: string,\n side: 'left' | 'right',\n rows: number,\n maxRows: number,\n): void {\n const key = `${target}:${side}`\n if (warnedCeilingKeys.has(key)) return\n warnedCeilingKeys.add(key)\n const pct = Math.round((rows / maxRows) * 100)\n console.warn(\n `[noy-db] .join() ${side} side is at ${pct}% of the ${maxRows}-row ` +\n `ceiling for target \"${target}\" (${rows} rows). Streaming joins over ` +\n `scan() are not yet supported for collections that need to exceed this.`,\n )\n}\n\n/**\n * Apply every join leg in the plan against a base set of left-side\n * rows. Called by the query executor after `where` / `orderBy` /\n * `offset` / `limit` have narrowed the left set.\n *\n * Each leg attaches a `leg.as` field to every row. Returns a new\n * array of plain objects — the original left rows are not mutated\n * (structural sharing is fine for the inner fields, but the\n * top-level object is a fresh clone so consumers can further mutate\n * safely).\n *\n * **Ordering:** joins run AFTER orderBy / limit / offset in v1.\n * This keeps the planner simple and means queries like \"top 10\n * invoices with client\" sort and paginate the left side first, then\n * join. Sorting *by* a joined field is out of scope for — users\n * can post-sort the result array in userland or wait for \n * (multi-FK chaining) which can be layered on top.\n *\n * **Multi-FK chaining:** each leg's `maxRows` is enforced\n * against the current left-row count independently. Because\n * joins are equi-joins on the target's primary key (one-to-one or\n * one-to-null), the left row count is constant across legs — no\n * cartesian blowup. The per-leg left-side check is still necessary\n * so that a later leg with a tighter ceiling correctly fires on a\n * query like `.join('a', { maxRows: 100_000 }).join('b', { maxRows: 50 })`,\n * which should throw on the second leg if the left set exceeds 50.\n */\nexport function applyJoins(\n rows: readonly unknown[],\n joins: readonly JoinLeg[],\n context: JoinContext,\n): unknown[] {\n if (joins.length === 0) return [...rows]\n\n let result: unknown[] = [...rows]\n for (const leg of joins) {\n result = applyOneJoin(result, leg, context)\n }\n return result\n}\n\nfunction applyOneJoin(\n leftRows: readonly unknown[],\n leg: JoinLeg,\n context: JoinContext,\n): unknown[] {\n // Dict join path — resolve left-field value against the\n // dictionary snapshot and attach { key, ...labels } under leg.as.\n if (leg.isDictJoin) {\n const dictSource = context.resolveDictSource?.(leg.field)\n if (!dictSource) {\n throw new Error(\n `.join() field \"${leg.field}\" on \"${context.leftCollection}\" is declared as a ` +\n `dictKey join but the dict source could not be resolved. ` +\n `Ensure the dictionary has at least one entry.`,\n )\n }\n const out: unknown[] = []\n const snapshot = dictSource.snapshot()\n const dictMap = new Map<string, unknown>()\n for (const entry of snapshot) {\n const k = readPath(entry, 'key')\n if (typeof k === 'string') dictMap.set(k, entry)\n }\n for (const left of leftRows) {\n const rawId = readPath(left, leg.field)\n const key = coerceRefKey(rawId)\n const dictEntry = key === null ? undefined : dictMap.get(key)\n out.push({ ...(left as Record<string, unknown>), [leg.as]: dictEntry ?? null })\n }\n return out\n }\n\n const source = context.resolveSource(leg.target)\n if (!source) {\n throw new Error(\n `.join() cannot resolve target collection \"${leg.target}\" ` +\n `(referenced from field \"${leg.field}\" on \"${context.leftCollection}\"). ` +\n `Make sure the target collection has been opened via vault.collection() ` +\n `at least once before running the query.`,\n )\n }\n\n const maxRows = leg.maxRows ?? DEFAULT_JOIN_MAX_ROWS\n\n // Per-leg left-side ceiling check. In a\n // multi-FK chain, each leg's `maxRows` is enforced independently\n // against the current left-row count, so\n // `.join('a', { maxRows: 100_000 }).join('b', { maxRows: 50 })`\n // correctly throws on the second leg if the left set exceeds 50.\n if (leftRows.length > maxRows) {\n throw new JoinTooLargeError({\n leftRows: leftRows.length,\n rightRows: -1,\n maxRows,\n side: 'left',\n message:\n `.join() left side has ${leftRows.length} rows, exceeding the ${maxRows}-row ` +\n `ceiling for target \"${leg.target}\". Filter the left side further with ` +\n `where()/limit() before joining, or raise the ceiling via { maxRows }. ` +\n `Streaming joins over scan() are not yet supported.`,\n })\n }\n if (leftRows.length > maxRows * JOIN_WARN_FRACTION) {\n warnCeilingApproaching(leg.target, 'left', leftRows.length, maxRows)\n }\n\n const rightSnapshot = source.snapshot()\n if (rightSnapshot.length > maxRows) {\n throw new JoinTooLargeError({\n leftRows: leftRows.length,\n rightRows: rightSnapshot.length,\n maxRows,\n side: 'right',\n message:\n `.join() right side \"${leg.target}\" has ${rightSnapshot.length} rows, ` +\n `exceeding the ${maxRows}-row ceiling. Raise the ceiling via { maxRows } ` +\n `if the data genuinely fits in memory, or track for streaming joins.`,\n })\n }\n if (rightSnapshot.length > maxRows * JOIN_WARN_FRACTION) {\n warnCeilingApproaching(leg.target, 'right', rightSnapshot.length, maxRows)\n }\n\n // Strategy selection: explicit override wins; otherwise prefer\n // nested-loop when the source exposes lookupById (O(1) per row),\n // falling back to hash join when it doesn't.\n const strategy: JoinStrategy =\n leg.strategy ?? (source.lookupById ? 'nested' : 'hash')\n\n if (strategy === 'nested' && source.lookupById) {\n // Bind through an arrow so the `this` context of lookupById\n // doesn't drift — same pattern as the existing candidateRecords\n // helper in builder.ts.\n const lookup = (id: string): unknown => source.lookupById?.(id)\n return nestedLoopJoin(leftRows, leg, lookup)\n }\n return hashJoin(leftRows, leg, rightSnapshot)\n}\n\nfunction nestedLoopJoin(\n leftRows: readonly unknown[],\n leg: JoinLeg,\n lookupById: (id: string) => unknown,\n): unknown[] {\n const out: unknown[] = []\n for (const left of leftRows) {\n const rawId = readPath(left, leg.field)\n const key = coerceRefKey(rawId)\n const right = key === null ? undefined : lookupById(key)\n out.push(attachJoin(left, leg, right, rawId))\n }\n return out\n}\n\nfunction hashJoin(\n leftRows: readonly unknown[],\n leg: JoinLeg,\n rightSnapshot: readonly unknown[],\n): unknown[] {\n // Build the right-side hash once per query execution. We key on\n // the `id` field because ref() always points to a target's primary\n // key — non-equi and non-id joins are out of scope for.\n const rightMap = new Map<string, unknown>()\n for (const record of rightSnapshot) {\n const rawId = readPath(record, 'id')\n const key = coerceRefKey(rawId)\n if (key !== null) {\n rightMap.set(key, record)\n }\n }\n const out: unknown[] = []\n for (const left of leftRows) {\n const rawId = readPath(left, leg.field)\n const key = coerceRefKey(rawId)\n const right = key === null ? undefined : rightMap.get(key)\n out.push(attachJoin(left, leg, right, rawId))\n }\n return out\n}\n\n/**\n * Attach the resolved right-side record (or null) to the left row\n * under the alias, applying ref-mode semantics for the dangling\n * case.\n *\n * A left-side record whose FK field is null/undefined is NOT a\n * dangling ref — it's \"no reference at all\", which is always\n * allowed regardless of mode. This matches the write-time\n * `enforceRefsOnPut` behavior: \"Nullish ref values are allowed —\n * treat them as 'no reference'.\"\n *\n * Only non-null FKs pointing at non-existent targets trigger the\n * mode behavior.\n */\nfunction attachJoin(\n left: unknown,\n leg: JoinLeg,\n right: unknown,\n rawId: unknown,\n): unknown {\n if (left === null || typeof left !== 'object') {\n // Pathological input — return as-is. Shouldn't happen in\n // practice because QuerySource yields objects, but defensive\n // because plan execution is untyped at this layer.\n return left\n }\n const merged: Record<string, unknown> = { ...(left as Record<string, unknown>) }\n\n // \"No ref at all\" — null/undefined FK value, or a non-string/non-\n // number FK that coerceRefKey treated as no-ref. Never throws\n // regardless of mode; matches the write-time policy that nullish\n // refs are allowed.\n const refKey = coerceRefKey(rawId)\n if (right === undefined) {\n if (refKey !== null && leg.mode === 'strict') {\n throw new DanglingReferenceError({\n field: leg.field,\n target: leg.target,\n refId: refKey,\n message:\n `.join() strict dangling: record references \"${leg.target}:${refKey}\" ` +\n `via field \"${leg.field}\", but no such record exists. Use ref() mode 'warn' ` +\n `or 'cascade' if dangling refs are acceptable, or run ` +\n `vault.checkIntegrity() to find and fix the orphans.`,\n })\n }\n if (refKey !== null && leg.mode === 'warn') {\n warnOnceDangling(leg.field, leg.target, refKey)\n }\n // For 'cascade' and null refs we attach null silently. Cascade\n // is a delete-time mode; any dangling refs visible at read time\n // are either mid-flight or pre-existing orphans, not a DSL error.\n merged[leg.as] = null\n } else {\n merged[leg.as] = right\n }\n return merged\n}\n\n/**\n * Test-only: reset the join warning deduplication state between\n * tests. Production code never calls this — the dedup state is\n * intentionally process-scoped so a noisy query doesn't spam the\n * console once per component render.\n */\nexport function resetJoinWarnings(): void {\n warnedDanglingKeys.clear()\n warnedCeilingKeys.clear()\n}\n","/**\n * Reactive query primitive — `query.live()`.\n *\n * produces a `LiveQuery<T>` that re-runs the query and\n * updates its `value` whenever any source feeding it (the left\n * collection AND every right-side collection a join leg points at)\n * mutates.\n *\n * Framework-agnostic by design. The Vue layer wraps a `LiveQuery`\n * in a Vue `Ref<T[]>` by subscribing once and copying `value` into\n * the ref on every notification. React/Solid/Svelte adapters do the\n * same with their own primitives. Core never depends on a UI\n * framework.\n *\n * **Error semantics.** A `.live()` query may throw at re-run time —\n * a strict-mode `DanglingReferenceError` is the most common case\n * (a right-side record was deleted out-of-band, leaving a left\n * row's FK pointing at nothing). When the re-run throws, the\n * `LiveQuery` catches the error and stores it in the `error`\n * field; it does NOT propagate the throw out of the source's\n * change handler, because doing so would tear down whatever\n * upstream emitter is dispatching. Listeners check `error` after\n * each notification and render an error state in the UI.\n *\n * **Dedup of right-side subscriptions.** A multi-FK chain that\n * joins the same target twice (e.g.\n * `.join('billingClientId').join('shippingClientId')`, both\n * pointing at `clients`) only subscribes to that target once. We\n * dedup by target collection name, on the assumption that\n * `resolveSource(name)` returns a single subscribable source per\n * vault + name. Vault's `resolveSource` reads from\n * `collectionCache` so this assumption holds.\n *\n * **What .live() does NOT do in v1:**\n * - No granular delta updates — the whole query re-runs on every\n * change. Granular delta tracking is a v2 optimization once\n * the API is stable.\n * - No batching of bursty changes — one event in, one re-run\n * out. Batching with microtask coalescing is a v2 enhancement.\n * - No async notifications — every notification is synchronous\n * within the source's change handler.\n * - No re-planning under live mutations — the planner picks once\n * at subscription time and reuses the same plan for every\n * re-run.\n */\n\n/**\n * The reactive primitive returned by `Query.live()`.\n *\n * Listeners can read the current `value` snapshot at any time and\n * subscribe to changes via `.subscribe(cb)`. The `error` field\n * carries the most recent re-run error, if any — read it after\n * each notification to render error state.\n *\n * Always call `stop()` when the live query is no longer needed.\n * Without it, the upstream change-stream subscriptions stay live\n * forever and the query keeps re-running on every mutation.\n */\nexport interface LiveQuery<T> {\n /**\n * Current snapshot of the query result. Updated in place on\n * every upstream change. The reference returned is the same\n * `readonly T[]` array — consumers that want change detection by\n * reference should copy: `const arr = [...live.value]`.\n */\n readonly value: readonly T[]\n /**\n * Most recent re-run error, or `null` on success. Set when the\n * executor throws (e.g. `DanglingReferenceError` in strict mode\n * after a right-side delete). Cleared on the next successful\n * re-run.\n */\n readonly error: Error | null\n /**\n * Register a notification callback. Fires AFTER `value` and\n * `error` have been updated for a given upstream change.\n * Returns an unsubscribe function.\n *\n * The first call to `subscribe` does NOT fire the callback\n * immediately — call sites that want the initial value should\n * read `live.value` directly before subscribing.\n */\n subscribe(cb: () => void): () => void\n /**\n * Tear down every upstream subscription and clear the listener\n * set. Idempotent — calling twice is safe. After `stop()`, the\n * query no longer re-runs and `subscribe()` becomes a no-op\n * (the returned unsubscribe is still callable and is also a\n * no-op).\n */\n stop(): void\n}\n\n/**\n * Internal subscription handle for an upstream source — left or\n * right side. The contract is just `subscribe(cb): unsubscribe`,\n * matching the existing `QuerySource.subscribe` and the new\n * `JoinableSource.subscribe` (added in ).\n */\nexport interface LiveUpstream {\n subscribe(cb: () => void): () => void\n}\n\n/**\n * Build a LiveQuery from a `recompute` callback (typically the\n * Query's bound `toArray`) and a list of upstream sources to\n * subscribe to.\n *\n * The recompute fires once synchronously to populate the initial\n * value, then re-fires every time any upstream notifies. Errors\n * thrown by recompute are caught and stored in `error` instead of\n * propagating — see the file docstring for the rationale.\n */\nexport function buildLiveQuery<T>(\n recompute: () => T[],\n upstreams: readonly LiveUpstream[],\n): LiveQuery<T> {\n return new LiveQueryImpl<T>(recompute, upstreams)\n}\n\nclass LiveQueryImpl<T> implements LiveQuery<T> {\n private _value: readonly T[] = []\n private _error: Error | null = null\n private readonly listeners = new Set<() => void>()\n private readonly unsubs: Array<() => void> = []\n private stopped = false\n\n constructor(\n private readonly recompute: () => T[],\n upstreams: readonly LiveUpstream[],\n ) {\n // Initial compute. If this throws, the constructor still\n // succeeds — we want consumers to be able to render an error\n // state from `live.error` rather than wrapping every\n // `query.live()` call in a try/catch.\n this.refresh()\n for (const upstream of upstreams) {\n try {\n this.unsubs.push(upstream.subscribe(this.onUpstreamChange))\n } catch (err) {\n // Upstream subscription failed — record it as the live\n // error and continue with the upstreams that did work.\n // The LiveQuery is now degraded (won't re-fire on this\n // upstream's changes) but isn't broken; consumers can\n // detect this via `live.error`.\n this._error = err instanceof Error ? err : new Error(String(err))\n }\n }\n }\n\n get value(): readonly T[] {\n return this._value\n }\n\n get error(): Error | null {\n return this._error\n }\n\n /**\n * Bound change handler — used as the callback passed to every\n * upstream's subscribe. Bound via class field so the `this`\n * context survives the indirect call from arbitrary upstreams.\n */\n private readonly onUpstreamChange = (): void => {\n this.refresh()\n for (const cb of this.listeners) {\n try {\n cb()\n } catch {\n // Listener errors are isolated — one buggy consumer\n // doesn't break the others or tear down the live query.\n }\n }\n }\n\n private refresh(): void {\n if (this.stopped) return\n try {\n this._value = this.recompute()\n this._error = null\n } catch (err) {\n this._error = err instanceof Error ? err : new Error(String(err))\n // Don't clobber the previous value on error — consumers\n // typically want to keep showing the last known good state\n // alongside the error message rather than flashing to an\n // empty list.\n }\n }\n\n subscribe(cb: () => void): () => void {\n if (this.stopped) return () => {}\n this.listeners.add(cb)\n return () => this.listeners.delete(cb)\n }\n\n stop(): void {\n if (this.stopped) return\n this.stopped = true\n for (const unsub of this.unsubs) {\n try {\n unsub()\n } catch {\n // Unsub errors are swallowed — at this point we're tearing\n // down anyway and the failure is noise.\n }\n }\n this.unsubs.length = 0\n this.listeners.clear()\n }\n}\n","/**\n * Strategy seam between the core Query / ScanBuilder chain and the\n * optional aggregate / groupBy subsystem. Core imports\n * `AggregateStrategy` as a TYPE-ONLY symbol and `NO_AGGREGATE` as a\n * tiny runtime stub.\n *\n * The heavy machinery — `Aggregation`, `GroupedQuery`, the\n * reducer-step logic — is only reachable from `withAggregate()` in\n * `./active.ts`, which is only exported through the\n * `@noy-db/hub/aggregate` subpath. Consumers that don't import the\n * subpath ship none of the ~886 LOC.\n *\n * @internal\n */\n\nimport type {\n Aggregation,\n AggregateSpec,\n AggregateResult,\n AggregationUpstream,\n} from './aggregation.js'\nimport type { GroupedQuery, GroupedQueryN } from './groupby.js'\n\n/**\n * Seam interface. `@internal` — will promote to public only when the\n * aggregate subsystem is extracted into its own package.\n *\n * @internal\n */\nexport interface AggregateStrategy {\n /**\n * Build an `Aggregation<R>` for `Query.aggregate(spec)`. `executeRecords`\n * is a closure that produces the matching record set when the\n * aggregation runs. NO_AGGREGATE throws; the active strategy\n * constructs a real `Aggregation`.\n */\n aggregate<Spec extends AggregateSpec>(\n executeRecords: () => readonly unknown[],\n spec: Spec,\n upstreams: readonly AggregationUpstream[],\n ): Aggregation<AggregateResult<Spec>>\n\n /**\n * Build a `GroupedQuery<T, F>` for `Query.groupBy(field)`. Same\n * closure / upstream inputs as `aggregate` plus the group key field.\n */\n groupBy<T, F extends string>(\n executeRecords: () => readonly unknown[],\n field: F,\n upstreams: readonly AggregationUpstream[],\n dictLabelResolver?: (\n key: string,\n locale: string,\n fallback?: string | readonly string[],\n ) => Promise<string | undefined>,\n ): GroupedQuery<T, F>\n\n /**\n * Variadic-keyed sibling — builds a `GroupedQueryN<T, F>` for\n * `Query.groupBy(...fields)`. No dictLabelResolver — `<field>Label`\n * projection only applies to single-field groupings, which dispatch\n * through `groupBy` above.\n */\n groupByN<T, F extends readonly string[]>(\n executeRecords: () => readonly unknown[],\n fields: F,\n upstreams: readonly AggregationUpstream[],\n ): GroupedQueryN<T, F>\n\n /**\n * Terminal streaming aggregator for `ScanBuilder.aggregate(spec)`.\n * Takes an async iterable of decrypted records + the spec and\n * returns the reduced result.\n */\n scanAggregate<Spec extends AggregateSpec>(\n iter: AsyncIterable<unknown>,\n spec: Spec,\n ): Promise<AggregateResult<Spec>>\n}\n\nconst NOT_ENABLED = new Error(\n 'Aggregate / groupBy is not enabled on this Noydb instance. ' +\n 'Import `{ withAggregate }` from \"@noy-db/hub/aggregate\" and pass it to ' +\n '`createNoydb({ aggregateStrategy: withAggregate() })`.',\n)\n\n/**\n * No-aggregate stub. Every `.aggregate()` / `.groupBy()` / streaming\n * `scan().aggregate()` call throws with a pointer at the subpath. The\n * real `Aggregation` / `GroupedQuery` classes are never referenced at\n * runtime, so the bundler drops the ~886 LOC.\n *\n * @internal\n */\nexport const NO_AGGREGATE: AggregateStrategy = {\n aggregate() { throw NOT_ENABLED },\n groupBy() { throw NOT_ENABLED },\n groupByN() { throw NOT_ENABLED },\n scanAggregate() { throw NOT_ENABLED },\n}\n","/**\n * Chainable, immutable query builder.\n *\n * Each builder operation returns a NEW Query — the underlying plan is never\n * mutated. This makes plans safe to share, cache, and serialize.\n */\n\nimport type { Clause, FieldClause, FilterClause, GroupClause, Operator, WherePredicateClause } from './predicate.js'\nimport { evaluateClause } from './predicate.js'\nimport type { CollectionIndexes } from '../indexing/eager-indexes.js'\nimport type { JoinContext, JoinLeg, JoinStrategy } from './join.js'\nimport { applyJoins } from './join.js'\nimport type { LiveQuery, LiveUpstream } from './live.js'\nimport { buildLiveQuery } from './live.js'\nimport type { AggregateSpec, AggregateResult, AggregationUpstream, Aggregation } from '../aggregate/aggregation.js'\nimport type { GroupedQuery, GroupedQueryN } from '../aggregate/groupby.js'\nimport { NO_AGGREGATE, type AggregateStrategy } from '../aggregate/strategy.js'\n\nexport interface OrderBy {\n readonly field: string\n readonly direction: 'asc' | 'desc'\n}\n\n/**\n * A complete query plan: zero-or-more clauses, optional ordering, pagination,\n * and optional joins.\n *\n * Plans are JSON-serializable as long as no FilterClause is present and no\n * join leg carries a manual `strategy` override (JoinLeg itself is plain\n * data, so it serializes cleanly).\n *\n * Plans are intentionally NOT parametric on T — see `predicate.ts` FilterClause\n * for the variance reasoning. The public `Query<T>` API attaches the type tag.\n */\nexport interface QueryPlan {\n readonly clauses: readonly Clause[]\n readonly orderBy: readonly OrderBy[]\n readonly limit: number | undefined\n readonly offset: number\n /**\n * Zero-or-more join legs to apply after where/orderBy/limit/offset.\n * Each leg attaches a resolved right-side record (or null) under its\n * alias. See `query/join.ts` for the full semantics.\n */\n readonly joins: readonly JoinLeg[]\n}\n\nconst EMPTY_PLAN: QueryPlan = {\n clauses: [],\n orderBy: [],\n limit: undefined,\n offset: 0,\n joins: [],\n}\n\n/**\n * Source of records that a query executes against.\n *\n * The interface is non-parametric to keep variance friendly: callers cast\n * their typed source (e.g. `QuerySource<Invoice>`) into this opaque shape.\n *\n * `getIndexes` and `lookupById` are optional fast-path hooks. When both are\n * present and a where clause matches an indexed field, the executor uses\n * the index to skip a linear scan. Sources without these methods (or with\n * `getIndexes` returning `null`) always fall back to a linear scan.\n */\nexport interface QuerySource<T> {\n /** Snapshot of all current records. The query never mutates this array. */\n snapshot(): readonly T[]\n /** Subscribe to mutations; returns an unsubscribe function. */\n subscribe?(cb: () => void): () => void\n /** Index store for the indexed-fast-path. Optional. */\n getIndexes?(): CollectionIndexes | null\n /** O(1) record lookup by id, used to materialize index hits. */\n lookupById?(id: string): T | undefined\n}\n\ninterface InternalSource {\n snapshot(): readonly unknown[]\n subscribe?(cb: () => void): () => void\n getIndexes?(): CollectionIndexes | null\n lookupById?(id: string): unknown\n}\n\n/**\n * The chainable builder. All methods return a new Query — the original\n * remains unchanged. Terminal methods (`toArray`, `first`, `count`,\n * `subscribe`) execute the plan against the source.\n *\n * Type parameter T flows through the public API for ergonomics, but the\n * internal storage uses `unknown` so Collection<T> stays covariant.\n *\n * The optional `joinContext` is attached when the Query is constructed\n * via `Collection.query()` (Collection passes in a context built from\n * the Vault's join resolver). A Query constructed via `new Query`\n * directly — e.g. from tests with a plain-object source — has no\n * joinContext, and calling `.join()` on it throws with an actionable\n * error. See `query/join.ts` for the full design.\n */\n/**\n * Declared deterministic predicate. Carries the consumer's\n * stable `hash` (for function-body identity), the function itself,\n * and is keyed by name when registered on a `Query<T>` via\n * `_withPredicates()`.\n */\nexport interface DeclaredPredicate {\n hash: string\n fn: (record: unknown, ctx?: unknown) => boolean\n}\n\nexport class Query<T> {\n private readonly source: InternalSource\n private readonly plan: QueryPlan\n private readonly joinContext: JoinContext | undefined\n private readonly aggregateStrategy: AggregateStrategy\n private readonly predicates: ReadonlyMap<string, DeclaredPredicate> | undefined\n\n constructor(\n source: QuerySource<T>,\n plan: QueryPlan = EMPTY_PLAN,\n joinContext?: JoinContext,\n aggregateStrategy: AggregateStrategy = NO_AGGREGATE,\n predicates?: ReadonlyMap<string, DeclaredPredicate>,\n ) {\n this.source = source as InternalSource\n this.plan = plan\n this.joinContext = joinContext\n this.aggregateStrategy = aggregateStrategy\n this.predicates = predicates\n }\n\n /**\n * @internal — accessor for the materialized-view dependency\n * analyzer. Not part of the public API; consumers should use the\n * builder methods, not inspect the plan directly.\n */\n _plan(): QueryPlan {\n return this.plan\n }\n\n /**\n * @internal — accessor for the materialized-view dependency\n * analyzer. Returns the join resolution context (or `undefined` for\n * queries constructed without a Collection backing).\n */\n _joinContext(): JoinContext | undefined {\n return this.joinContext\n }\n\n /**\n * @internal — clone this Query with a declared-predicate map\n * attached. Used by the materialized-view registry to enable\n * `.wherePredicate(name, ctx?)` for the MV's query callback.\n * Consumers don't call this directly.\n */\n _withPredicates(predicates: ReadonlyMap<string, DeclaredPredicate>): Query<T> {\n return new Query<T>(\n this.source as QuerySource<T>,\n this.plan,\n this.joinContext,\n this.aggregateStrategy,\n predicates,\n )\n }\n\n /**\n * Filter by a registered deterministic predicate. Requires\n * the Query to have been augmented with a predicates map (typically\n * via the materialized-view registry — bare Queries constructed\n * outside an MV throw on `.wherePredicate()`).\n *\n * `ctx` is an optional opaque value passed verbatim to the predicate\n * function. Both `predicateHash` (from the registration) and a\n * canonical-JSON hash of `ctx` fold into the MV's `queryHash`, so\n * either changing forces refresh on next visit.\n */\n wherePredicate(name: string, ctx?: unknown): Query<T> {\n if (!this.predicates) {\n throw new Error(\n `.wherePredicate(\"${name}\"): no predicates registered on this Query. ` +\n `Function-based predicates require the Query to be obtained from ` +\n `inside a materialized-view query() callback whose strategy declares ` +\n `\\`predicates: { ${name}: { hash, fn } }\\`.`,\n )\n }\n const decl = this.predicates.get(name)\n if (!decl) {\n throw new Error(\n `.wherePredicate(\"${name}\"): predicate not registered. ` +\n `Available: ${[...this.predicates.keys()].join(', ') || '(none)'}.`,\n )\n }\n const clause: WherePredicateClause = {\n type: 'wherePredicate',\n name,\n ctx,\n predicateHash: decl.hash,\n ctxHash: canonicalCtxHash(ctx),\n fn: decl.fn,\n }\n return new Query<T>(\n this.source as QuerySource<T>,\n { ...this.plan, clauses: [...this.plan.clauses, clause] },\n this.joinContext,\n this.aggregateStrategy,\n this.predicates,\n )\n }\n\n /** Add a field comparison. Multiple where() calls are AND-combined. */\n where(field: string, op: Operator, value: unknown): Query<T> {\n const clause: FieldClause = { type: 'field', field, op, value }\n return new Query<T>(\n this.source as QuerySource<T>,\n { ...this.plan, clauses: [...this.plan.clauses, clause] },\n this.joinContext,\n this.aggregateStrategy,\n this.predicates,\n )\n }\n\n /**\n * Logical OR group. Pass a callback that builds a sub-query.\n * Each clause inside the callback is OR-combined; the group itself\n * joins the parent plan with AND.\n */\n or(builder: (q: Query<T>) => Query<T>): Query<T> {\n const sub = builder(\n new Query<T>(this.source as QuerySource<T>, EMPTY_PLAN, this.joinContext, this.aggregateStrategy, this.predicates),\n )\n const group: GroupClause = {\n type: 'group',\n op: 'or',\n clauses: sub.plan.clauses,\n }\n return new Query<T>(\n this.source as QuerySource<T>,\n { ...this.plan, clauses: [...this.plan.clauses, group] },\n this.joinContext,\n this.aggregateStrategy,\n this.predicates,\n )\n }\n\n /**\n * Logical AND group. Same shape as `or()` but every clause inside the group\n * must match. Useful for explicit grouping inside a larger OR.\n */\n and(builder: (q: Query<T>) => Query<T>): Query<T> {\n const sub = builder(\n new Query<T>(this.source as QuerySource<T>, EMPTY_PLAN, this.joinContext, this.aggregateStrategy, this.predicates),\n )\n const group: GroupClause = {\n type: 'group',\n op: 'and',\n clauses: sub.plan.clauses,\n }\n return new Query<T>(\n this.source as QuerySource<T>,\n { ...this.plan, clauses: [...this.plan.clauses, group] },\n this.joinContext,\n this.aggregateStrategy,\n this.predicates,\n )\n }\n\n /** Escape hatch: add an arbitrary predicate function. Not serializable. */\n filter(fn: (record: T) => boolean): Query<T> {\n const clause: FilterClause = {\n type: 'filter',\n fn: fn as (record: unknown) => boolean,\n }\n return new Query<T>(\n this.source as QuerySource<T>,\n { ...this.plan, clauses: [...this.plan.clauses, clause] },\n this.joinContext,\n this.aggregateStrategy,\n this.predicates,\n )\n }\n\n /** Sort by a field. Subsequent calls are tie-breakers. */\n orderBy(field: string, direction: 'asc' | 'desc' = 'asc'): Query<T> {\n return new Query<T>(\n this.source as QuerySource<T>,\n { ...this.plan, orderBy: [...this.plan.orderBy, { field, direction }] },\n this.joinContext,\n this.aggregateStrategy,\n this.predicates,\n )\n }\n\n /** Cap the result size. */\n limit(n: number): Query<T> {\n return new Query<T>(\n this.source as QuerySource<T>,\n { ...this.plan, limit: n },\n this.joinContext,\n this.aggregateStrategy,\n this.predicates,\n )\n }\n\n /** Skip the first N matching records (after ordering). */\n offset(n: number): Query<T> {\n return new Query<T>(\n this.source as QuerySource<T>,\n { ...this.plan, offset: n },\n this.joinContext,\n this.aggregateStrategy,\n this.predicates,\n )\n }\n\n /**\n * Resolve a `ref()`-declared foreign key and attach the right-side\n * record under `opts.as`. — eager, single-FK, intra-\n * vault joins.\n *\n * ```ts\n * const rows = invoices.query()\n * .where('status', '==', 'open')\n * .join('clientId', { as: 'client' })\n * .toArray()\n * // → [{ id, amount, client: { id, name, ... } }, ...]\n * ```\n *\n * Preconditions:\n * - The Query must have a `joinContext` (constructed via\n * `Collection.query()`, not `new Query`).\n * - `field` must have a matching `refs: { [field]: ref('<target>') }`\n * declaration on the left collection.\n * - The target collection must be reachable via the vault\n * (either currently open or openable on demand).\n *\n * Strategy:\n * - Nested-loop against `lookupById` when the target source\n * provides it (the common path for Collection targets).\n * - Hash join otherwise, or when `{ strategy: 'hash' }` is\n * explicitly passed for test purposes.\n *\n * Ref-mode semantics on dangling refs (left record has a non-null\n * FK value pointing at a right-side id that doesn't exist):\n * - `strict` → throws `DanglingReferenceError` with the full\n * field / target / refId context.\n * - `warn` → attaches `null` and emits a one-shot warning per\n * unique dangling pair.\n * - `cascade` → attaches `null` silently. Cascade is a\n * delete-time mode; dangling refs visible at read time are\n * either mid-flight cascades or pre-existing orphans, not a\n * DSL-level error.\n *\n * A left-side record whose FK field is `null` / `undefined` is NOT\n * a dangling ref — it's \"no reference at all\", always allowed\n * regardless of mode.\n *\n * The return type widens `T` with `Record<As, R | null>`. The `R`\n * parameter is optional — supply it explicitly for type-checked\n * access to the joined fields:\n *\n * ```ts\n * invoices.query().join<'client', Client>('clientId', { as: 'client' })\n * // ^^^^^^^^^^^^^^^^^^^ alias literal + right-side type\n * ```\n *\n * Without the generic, the joined field is typed as `unknown`, which\n * still works but requires a cast to access its properties.\n *\n * Joins stay intra-vault by construction — cross-vault\n * correlation goes through `Noydb.queryAcross`, not\n * `.join()`.\n */\n join<As extends string, R = unknown>(\n field: string,\n opts: { as: As; strategy?: JoinStrategy; maxRows?: number },\n ): Query<T & Record<As, R | null>> {\n if (!this.joinContext) {\n throw new Error(\n `Query.join() requires a join context. Use collection.query() ` +\n `to construct a join-capable Query instead of the Query constructor ` +\n `directly (the direct constructor is only used for tests with ` +\n `plain-object sources).`,\n )\n }\n const descriptor = this.joinContext.resolveRef(field)\n // Check for dictKey join when no ref() is declared\n const isDictJoinField = !descriptor && this.joinContext.resolveDictSource?.(field) != null\n if (!descriptor && !isDictJoinField) {\n throw new Error(\n `Query.join(): no ref() declared for field \"${field}\" on collection ` +\n `\"${this.joinContext.leftCollection}\". Add ` +\n `refs: { ${field}: ref('<target-collection>') } to the collection ` +\n `options, then retry. See the ref() docs for the full list of modes.`,\n )\n }\n const leg: JoinLeg = descriptor\n ? {\n field,\n as: opts.as,\n target: descriptor.target,\n mode: descriptor.mode,\n strategy: opts.strategy,\n maxRows: opts.maxRows,\n // constraint #1 — always 'all' in. Do not remove.\n partitionScope: 'all',\n }\n : {\n // Dict join leg\n field,\n as: opts.as,\n target: field, // dict name = field name for dictKey\n mode: 'strict',\n strategy: opts.strategy,\n maxRows: opts.maxRows,\n partitionScope: 'all',\n isDictJoin: true,\n }\n return new Query<T & Record<As, R | null>>(\n this.source as unknown as QuerySource<T & Record<As, R | null>>,\n { ...this.plan, joins: [...this.plan.joins, leg] },\n this.joinContext,\n this.aggregateStrategy,\n this.predicates,\n )\n }\n\n /**\n * Execute the plan and return the matching records. When the plan\n * carries any join legs, they are applied after `where` / `orderBy`\n * / `limit` / `offset` narrow the left set. See the `.join()` doc\n * for the ordering rationale.\n */\n toArray(): T[] {\n const base = executePlanWithSource(this.source, this.plan)\n if (this.plan.joins.length === 0) return base as T[]\n if (!this.joinContext) {\n // Unreachable in practice — .join() throws if joinContext is\n // missing — but belt-and-braces for direct plan construction.\n throw new Error(\n `Query.toArray(): plan carries ${this.plan.joins.length} join leg(s) ` +\n `but no JoinContext is attached. This usually means the Query was ` +\n `constructed via the raw Query constructor with a plan that had joins ` +\n `pre-populated. Use collection.query().join(...) instead.`,\n )\n }\n return applyJoins(base, this.plan.joins, this.joinContext) as T[]\n }\n\n /** Return the first matching record, or null. Joins are applied. */\n first(): T | null {\n const arr = this.limit(1).toArray()\n return arr[0] ?? null\n }\n\n /**\n * Return the number of matching records (after where/filter,\n * before limit). **Joins are NOT applied** — count() reports the\n * left-side cardinality, because joins in are projection-only\n * (they attach an aliased field; they never filter). Running joins\n * here just to discard the aliases would be wasteful, and in strict\n * mode it could throw `DanglingReferenceError` for a call whose\n * intent is purely to count.\n */\n count(): number {\n // Use the same index-aware candidate machinery as toArray(); skip the\n // index-driving clause from re-evaluation. The length BEFORE limit/offset\n // is what `count()` documents.\n const { candidates, remainingClauses } = candidateRecords(this.source, this.plan.clauses)\n if (remainingClauses.length === 0) return candidates.length\n return filterRecords(candidates, remainingClauses).length\n }\n\n /**\n * Reduce the matching records through a named set of reducers.\n * the aggregation terminal.\n *\n * ```ts\n * const { total, n, avgAmount } = invoices.query()\n * .where('status', '==', 'open')\n * .aggregate({\n * total: sum('amount'),\n * n: count(),\n * avgAmount: avg('amount'),\n * })\n * .run()\n * ```\n *\n * Returns an `Aggregation<R>` wrapper with two terminals:\n * - `.run(): R` — synchronous one-shot reduction\n * - `.live(): LiveAggregation<R>` — reactive primitive that\n * re-runs the reduction whenever the source notifies of a\n * change. Always call `live.stop()` when finished.\n *\n * The reducer spec is bound here once and reused by both\n * terminals — this is why `.aggregate()` returns a wrapper instead\n * of being a direct terminal. Consumers who only need the static\n * value read `.run()`; consumers wiring a reactive UI read\n * `.live()`.\n *\n * Joins are intentionally NOT applied to aggregations in —\n * the same logic as `.count()`. Joins in are projection-only\n * (they attach an aliased field and never filter), so running\n * them just to throw the aliases away would be wasteful. If you\n * need a reducer that reads a joined field, open an issue —\n * aggregations-across-joins is explicitly out of scope for v1.\n *\n * Every reducer factory accepts an optional `{ seed }` parameter\n * that is plumbed through the protocol but unused by the\n * executor — that's constraint #2. When partition-aware\n * aggregation lands, the seed will carry running state across\n * partition boundaries without an API break.\n */\n aggregate<Spec extends AggregateSpec>(\n spec: Spec,\n ): Aggregation<AggregateResult<Spec>> {\n // Closure over the current query. Produces the record set that\n // the aggregation reduces — same pipeline as `count()`, skipping\n // limit/offset because aggregation is over the full match set,\n // not a paginated slice. (A paginated aggregation would be a\n // different operation; see docs for rationale.)\n const source = this.source\n const clauses = this.plan.clauses\n const executeRecords = (): readonly unknown[] => {\n const { candidates, remainingClauses } = candidateRecords(source, clauses)\n return remainingClauses.length === 0\n ? candidates\n : filterRecords(candidates, remainingClauses)\n }\n\n // Upstream for live mode — only the left source subscribes.\n // Joined aggregations are out of scope for (see above), so\n // there are no right-side change streams to merge in.\n const upstreams: AggregationUpstream[] = []\n if (source.subscribe) {\n const subscribe = source.subscribe.bind(source)\n upstreams.push({ subscribe: (cb: () => void) => subscribe(cb) })\n }\n\n return this.aggregateStrategy.aggregate<Spec>(executeRecords, spec, upstreams)\n }\n\n /**\n * Partition matching records into buckets keyed by a field, then\n * terminate with `.aggregate(spec)` to compute per-bucket\n * reducers..\n *\n * ```ts\n * const byClient = invoices.query()\n * .where('status', '==', 'open')\n * .groupBy('clientId')\n * .aggregate({ total: sum('amount'), n: count() })\n * .run()\n * // → [ { clientId: 'c1', total: 5250, n: 3 }, … ]\n * ```\n *\n * Result rows carry the group key value under the grouping field\n * name plus every reducer output from the spec. Buckets are\n * emitted in first-seen order — consumers who want a specific\n * ordering should `.sort()` downstream.\n *\n * **Cardinality caps:** a one-shot warning fires at 10_000\n * distinct groups; `GroupCardinalityError` throws at 100_000.\n * Grouping on a high-uniqueness field like `id` or `createdAt` is\n * almost always a query mistake — the error message names the\n * field and observed cardinality and suggests narrowing with\n * `.where()` first.\n *\n * **Null / undefined keys:** records with a missing or explicitly\n * `null` group field get their own buckets. `Map`-based\n * partitioning distinguishes `undefined` from `null`, so the two\n * cases do NOT merge. Consumers who want them merged should\n * coalesce upstream with `.filter()`.\n *\n * **Joins are not applied** — same rationale as `.count()` and\n * `.aggregate()`. Joined fields in are projection-only, so\n * running a join inside a grouping pipeline would be wasteful and\n * could trigger `DanglingReferenceError` in strict mode for a\n * call whose intent is purely to bucket-and-reduce. Grouping by\n * a joined field is explicitly out of scope for — file an\n * issue if a real consumer needs it.\n *\n * **Filter clauses (`.filter(fn)`):** grouped queries still\n * support filter clauses in the underlying plan — they run in\n * the same candidate/filter pipeline that `.aggregate()` uses.\n * The performance caveat is the same: filter clauses cost O(N)\n * per record and can't be index-accelerated.\n */\n groupBy<F extends string>(field: F): GroupedQuery<T, F>\n groupBy<F extends readonly [string, string, ...string[]]>(\n ...fields: F\n ): GroupedQueryN<T, F>\n groupBy(...fields: readonly string[]): GroupedQuery<T, string> | GroupedQueryN<T, readonly string[]> {\n if (fields.length === 0) {\n throw new Error('.groupBy() requires at least one field')\n }\n // Same record-producing closure as .aggregate() — grouped and\n // non-grouped aggregations execute over the same candidate set.\n // We inline the closure here instead of sharing a helper so the\n // builder stays allocation-friendly for the hot path.\n const source = this.source\n const clauses = this.plan.clauses\n const executeRecords = (): readonly unknown[] => {\n const { candidates, remainingClauses } = candidateRecords(source, clauses)\n return remainingClauses.length === 0\n ? candidates\n : filterRecords(candidates, remainingClauses)\n }\n\n const upstreams: AggregationUpstream[] = []\n if (source.subscribe) {\n const subscribe = source.subscribe.bind(source)\n upstreams.push({ subscribe: (cb: () => void) => subscribe(cb) })\n }\n\n // Dict-label resolution is single-field only — the <field>Label\n // projection has no meaningful shape for composite keys.\n if (fields.length === 1) {\n const field = fields[0]!\n const dictLabelResolver = buildDictLabelResolver(this.joinContext, field)\n return this.aggregateStrategy.groupBy<T, string>(\n executeRecords,\n field,\n upstreams,\n dictLabelResolver,\n )\n }\n return this.aggregateStrategy.groupByN<T, readonly string[]>(\n executeRecords,\n fields,\n upstreams,\n )\n }\n\n /**\n * Re-run the query whenever the source notifies of changes.\n * Returns an unsubscribe function. The callback receives the latest result.\n * Throws if the source does not support subscriptions.\n *\n * **For joined queries, prefer `.live()`** — `subscribe()`\n * only re-fires on LEFT-side changes, so joined data can be\n * stale if the right side mutates between emissions. `.live()`\n * merges change streams from every join target.\n */\n subscribe(cb: (result: T[]) => void): () => void {\n if (!this.source.subscribe) {\n throw new Error('Query source does not support subscriptions. Pass a source with a subscribe() method.')\n }\n cb(this.toArray())\n return this.source.subscribe(() => cb(this.toArray()))\n }\n\n /**\n * Reactive terminal — returns a `LiveQuery<T>` that re-runs the\n * query and updates its `value` whenever any source feeding it\n * mutates..\n *\n * For non-joined queries, `.live()` is a convenience over the\n * existing `.subscribe()` callback shape: a hand-rolled reactive\n * primitive with `value` / `error` fields and a `subscribe(cb)`\n * notification channel. Frame-agnostic — Vue / React / Solid\n * adapters wrap it in their own primitive.\n *\n * For joined queries, `.live()` additionally subscribes to every\n * join target's change stream. Mutations on a right-side\n * collection (insert / update / delete of a client referenced by\n * an invoice) re-fire the live query and re-evaluate every\n * dependent left row. Right-side targets are deduped by\n * collection name, so a chain that joins the same target twice\n * (e.g. billing client + shipping client → both 'clients') only\n * subscribes once.\n *\n * **Ref-mode behavior on right-side disappearance** — matches the\n * eager `.toArray()` contract from :\n * - `strict` → re-run throws `DanglingReferenceError`. The\n * LiveQuery catches the throw, stores it in `live.error`, and\n * notifies listeners (the throw does NOT propagate out of\n * the source's change handler — that would tear down the\n * emitter). Consumers check `live.error` after each\n * notification and render an error state in the UI.\n * - `warn` → joined value flips to `null`; the existing\n * warn-channel deduplication keeps repeated re-runs from\n * spamming the console.\n * - `cascade` → no special handling needed; the cascade-\n * delete mechanism propagates the right-side delete into the\n * left collection on the next tick, and the live query\n * naturally re-fires with the orphaned left rows gone.\n *\n * Always call `live.stop()` when finished — it tears down every\n * upstream subscription. The Vue layer's `onUnmounted` hook\n * should call `stop()` automatically; raw consumers must do it\n * themselves.\n *\n * **Limitations:**\n * - No granular delta updates — the whole query re-runs on\n * every change.\n * - No microtask batching — bursty changes produce one re-run\n * per change.\n * - No re-planning under live mutations — the planner picks\n * once at subscription time and reuses the same plan.\n * - Streaming live joins are deferred.\n */\n live(): LiveQuery<T> {\n const upstreams: LiveUpstream[] = []\n\n // Left-side change stream — every live query subscribes to\n // its source if the source supports subscriptions.\n if (this.source.subscribe) {\n const leftSubscribe = this.source.subscribe.bind(this.source)\n upstreams.push({\n subscribe: (cb: () => void) => leftSubscribe(cb),\n })\n }\n\n // Right-side change streams — only for joined queries. Dedup\n // by target name so a chain joining the same target twice\n // doesn't double-subscribe and double-fire on every right-side\n // mutation.\n if (this.plan.joins.length > 0 && this.joinContext) {\n const subscribed = new Set<string>()\n for (const leg of this.plan.joins) {\n if (subscribed.has(leg.target)) continue\n subscribed.add(leg.target)\n const rightSource = this.joinContext.resolveSource(leg.target)\n if (rightSource?.subscribe) {\n const rightSubscribe = rightSource.subscribe.bind(rightSource)\n upstreams.push({\n subscribe: (cb: () => void) => rightSubscribe(cb),\n })\n }\n }\n }\n\n // The recompute is just toArray bound to this query — same\n // pipeline as eager execution, including join application.\n return buildLiveQuery<T>(() => this.toArray(), upstreams)\n }\n\n /**\n * Return the plan as a JSON-friendly object. FilterClause entries are\n * stripped (their `fn` cannot be serialized) and replaced with\n * { type: 'filter', fn: '[function]' } so devtools can still see them.\n */\n toPlan(): unknown {\n return serializePlan(this.plan)\n }\n}\n\n/**\n * Index-aware execution: try the indexed fast path first, fall back to a\n * full scan otherwise. Mirrors `executePlan` for the public surface but\n * takes a `QuerySource` so it can consult `getIndexes()` and `lookupById()`.\n */\nfunction executePlanWithSource(source: InternalSource, plan: QueryPlan): unknown[] {\n const { candidates, remainingClauses } = candidateRecords(source, plan.clauses)\n // Only the clauses NOT consumed by the index need re-evaluation. This is\n // the key optimization that makes indexed queries dominate linear scans:\n // for a single-clause query against an indexed field, `remainingClauses`\n // is empty and we skip the per-record predicate evaluation entirely.\n let result = remainingClauses.length === 0\n ? [...candidates]\n : filterRecords(candidates, remainingClauses)\n if (plan.orderBy.length > 0) {\n result = sortRecords(result, plan.orderBy)\n }\n if (plan.offset > 0) {\n result = result.slice(plan.offset)\n }\n if (plan.limit !== undefined) {\n result = result.slice(0, plan.limit)\n }\n return result\n}\n\ninterface CandidateResult {\n /** The reduced candidate set, materialized to record objects. */\n readonly candidates: readonly unknown[]\n /** The clauses that the index could not satisfy and must still be evaluated. */\n readonly remainingClauses: readonly Clause[]\n}\n\n/**\n * Pick a candidate record set using the index store when possible.\n *\n * Strategy: scan the top-level clauses for the FIRST `==` or `in` clause\n * against an indexed field. If found, use the index to materialize a\n * candidate set and return the OTHER clauses as `remainingClauses`. The\n * caller skips re-evaluating the index-driving clause because the index\n * is authoritative for that field.\n *\n * This is a deliberately simple planner. A future optimizer could pick\n * the most selective index, intersect multiple indexes, or push composite\n * keys through. For the single-index fast path is good enough.\n */\nfunction candidateRecords(source: InternalSource, clauses: readonly Clause[]): CandidateResult {\n const indexes = source.getIndexes?.()\n if (!indexes || !source.lookupById || clauses.length === 0) {\n return { candidates: source.snapshot(), remainingClauses: clauses }\n }\n // Bind the lookup method through an arrow so it doesn't drift from\n // its `this` context — keeps the unbound-method lint rule happy.\n const lookupById = (id: string): unknown => source.lookupById?.(id)\n\n for (let i = 0; i < clauses.length; i++) {\n const clause = clauses[i]!\n if (clause.type !== 'field') continue\n if (!indexes.has(clause.field)) continue\n\n let ids: ReadonlySet<string> | null = null\n if (clause.op === '==') {\n ids = indexes.lookupEqual(clause.field, clause.value)\n } else if (clause.op === 'in' && Array.isArray(clause.value)) {\n ids = indexes.lookupIn(clause.field, clause.value)\n }\n\n if (ids !== null) {\n // Found an index-eligible clause: materialize the candidate set and\n // remove this clause from the remaining list.\n const remaining: Clause[] = []\n for (let j = 0; j < clauses.length; j++) {\n if (j !== i) remaining.push(clauses[j]!)\n }\n return {\n candidates: materializeIds(ids, lookupById),\n remainingClauses: remaining,\n }\n }\n // Not index-eligible — keep scanning in case a later clause is a\n // better candidate.\n }\n\n // No clause was index-eligible — fall back to a full scan.\n return { candidates: source.snapshot(), remainingClauses: clauses }\n}\n\nfunction materializeIds(\n ids: ReadonlySet<string>,\n lookupById: (id: string) => unknown,\n): unknown[] {\n const out: unknown[] = []\n for (const id of ids) {\n const record = lookupById(id)\n if (record !== undefined) out.push(record)\n }\n return out\n}\n\n/**\n * Execute a plan against a snapshot of records.\n * Pure function — same input, same output, no side effects.\n *\n * Records are typed as `unknown` because plans are non-parametric; callers\n * cast the return type at the API surface (see `Query.toArray()`).\n */\nexport function executePlan(records: readonly unknown[], plan: QueryPlan): unknown[] {\n let result = filterRecords(records, plan.clauses)\n if (plan.orderBy.length > 0) {\n result = sortRecords(result, plan.orderBy)\n }\n if (plan.offset > 0) {\n result = result.slice(plan.offset)\n }\n if (plan.limit !== undefined) {\n result = result.slice(0, plan.limit)\n }\n return result\n}\n\nfunction filterRecords(records: readonly unknown[], clauses: readonly Clause[]): unknown[] {\n if (clauses.length === 0) return [...records]\n const out: unknown[] = []\n for (const r of records) {\n let matches = true\n for (const clause of clauses) {\n if (!evaluateClause(r, clause)) {\n matches = false\n break\n }\n }\n if (matches) out.push(r)\n }\n return out\n}\n\nfunction sortRecords(records: unknown[], orderBy: readonly OrderBy[]): unknown[] {\n // Stable sort: Array.prototype.sort is required to be stable since ES2019.\n return [...records].sort((a, b) => {\n for (const { field, direction } of orderBy) {\n const av = readField(a, field)\n const bv = readField(b, field)\n const cmp = compareValues(av, bv)\n if (cmp !== 0) return direction === 'asc' ? cmp : -cmp\n }\n return 0\n })\n}\n\nfunction readField(record: unknown, field: string): unknown {\n if (record === null || record === undefined) return undefined\n if (!field.includes('.')) {\n return (record as Record<string, unknown>)[field]\n }\n const segments = field.split('.')\n let cursor: unknown = record\n for (const segment of segments) {\n if (cursor === null || cursor === undefined) return undefined\n cursor = (cursor as Record<string, unknown>)[segment]\n }\n return cursor\n}\n\nfunction compareValues(a: unknown, b: unknown): number {\n // Nullish goes last in asc order.\n if (a === undefined || a === null) return b === undefined || b === null ? 0 : 1\n if (b === undefined || b === null) return -1\n if (typeof a === 'number' && typeof b === 'number') return a - b\n if (typeof a === 'string' && typeof b === 'string') return a < b ? -1 : a > b ? 1 : 0\n if (a instanceof Date && b instanceof Date) return a.getTime() - b.getTime()\n // Mixed/unsupported types: treat as equal so the sort stays stable.\n // (Deliberate choice — we don't try to coerce arbitrary objects to strings.)\n return 0\n}\n\nfunction serializePlan(plan: QueryPlan): unknown {\n return {\n clauses: plan.clauses.map(serializeClause),\n orderBy: plan.orderBy,\n limit: plan.limit,\n offset: plan.offset,\n joins: plan.joins,\n }\n}\n\nfunction serializeClause(clause: Clause): unknown {\n if (clause.type === 'filter') {\n return { type: 'filter', fn: '[function]' }\n }\n if (clause.type === 'wherePredicate') {\n // Strip the live `fn` reference (non-serializable) but keep the\n // identity-carrying fields so distinct predicates still serialize\n // distinctly. `predicateHash` + `ctxHash` are the hash identity;\n // `name` is the named predicate reference. This matters because\n // A previous fall-through (return clause) exposed the live fn and produced\n // identical serializations for distinct predicates with different ctx values.\n return {\n type: 'wherePredicate',\n name: clause.name,\n ctx: clause.ctx,\n predicateHash: clause.predicateHash,\n ctxHash: clause.ctxHash,\n fn: '[function]',\n }\n }\n if (clause.type === 'group') {\n return {\n type: 'group',\n op: clause.op,\n clauses: clause.clauses.map(serializeClause),\n }\n }\n return clause\n}\n\n/**\n * Compute a stable hash of a `ctx` value supplied to\n * `.wherePredicate(name, ctx)`. Canonical-JSON: keys sorted at each\n * level so `{a, b}` and `{b, a}` hash to the same value. Undefined ctx\n * hashes to the empty string. The hash is sync because it just runs\n * a cheap djb2-style fold — used at builder time, not security-sensitive.\n *\n * @internal\n */\nfunction canonicalCtxHash(ctx: unknown): string {\n if (ctx === undefined) return \"\"\n const canonical = JSON.stringify(ctx, (_key, value) => {\n if (value && typeof value === \"object\" && !Array.isArray(value)) {\n const sorted: Record<string, unknown> = {}\n for (const k of Object.keys(value as Record<string, unknown>).sort()) {\n sorted[k] = (value as Record<string, unknown>)[k]\n }\n return sorted\n }\n return value\n })\n // djb2 fold over the canonical string; converted to hex.\n let h = 5381\n for (let i = 0; i < canonical.length; i++) {\n h = ((h << 5) + h) ^ canonical.charCodeAt(i)\n }\n return (h >>> 0).toString(16).padStart(8, \"0\")\n}\n\n/**\n * Build a dict-label resolver for `Query.groupBy(field)` when the\n * grouping field is a `dictKey`. Extracted from the inline closure\n * inside `groupBy` so the multi-key path (which has no meaningful\n * `<field>Label` shape) can skip it cleanly. Pure refactor — no\n * behaviour change for the single-field path.\n *\n * Returns `undefined` when:\n * - the join context lacks a `resolveDictSource` hook, or\n * - no dictionary source is registered for `field`.\n *\n * @internal\n */\nfunction buildDictLabelResolver(\n joinCtx: JoinContext | undefined,\n field: string,\n):\n | ((key: string, locale: string, fallback?: string | readonly string[]) => Promise<string | undefined>)\n | undefined {\n if (!joinCtx?.resolveDictSource) return undefined\n const dictSource = joinCtx.resolveDictSource(field)\n if (!dictSource) return undefined\n const snapshot = dictSource.snapshot()\n const dictMap = new Map<string, Record<string, string>>()\n for (const entry of snapshot) {\n const k = (entry as Record<string, unknown>)['key']\n const labels = (entry as Record<string, unknown>)['labels']\n if (typeof k === 'string' && labels && typeof labels === 'object') {\n dictMap.set(k, labels as Record<string, string>)\n }\n }\n return async (\n key: string,\n locale: string,\n fallback?: string | readonly string[],\n ): Promise<string | undefined> => {\n const labels = dictMap.get(key)\n if (!labels) return undefined\n if (labels[locale] !== undefined) return labels[locale]\n const chain = Array.isArray(fallback)\n ? (fallback as readonly string[])\n : fallback\n ? [fallback as string]\n : []\n for (const fb of chain) {\n if (fb === 'any') {\n const any = Object.values(labels)[0]\n if (any !== undefined) return any\n } else if (labels[fb] !== undefined) {\n return labels[fb]\n }\n }\n return undefined\n }\n}\n","/**\n * Streaming scan builder with filter + aggregate support.\n *\n * `Collection.scan()` now returns a `ScanBuilder<T>` that\n * implements `AsyncIterable<T>` (for existing `for await … of`\n * consumers) AND exposes chainable `.where()` / `.filter()` clauses\n * plus a `.aggregate(spec)` async terminal that reduces the scan\n * stream through the same reducer protocol as `Query.aggregate()`\n *.\n *\n * **Memory model:** O(reducers), not O(records). The aggregate\n * terminal initializes one state per reducer, iterates through the\n * scan one record at a time via `for await`, applies every reducer's\n * `step` per record, and never collects the stream into an array.\n * This is what makes `scan().aggregate()` suitable for collections\n * that don't fit in memory — the bound is a code-level invariant\n * visible in the function body, not a runtime assertion.\n *\n * **Paginated iteration:** the builder holds a `pageProvider`\n * closure that maps `(cursor, limit) → Promise<page>`, plumbed by\n * `Collection.scan()` to `collection.listPage(...)`. The page\n * iterator walks cursors forward until exhaustion, same as the\n * previous async-generator `scan()` did.\n *\n * **Backward compatibility:** existing `for await (const rec of\n * collection.scan()) { … }` code continues to work because\n * `ScanBuilder` implements `[Symbol.asyncIterator]`. The previous\n * signature returned an `AsyncIterableIterator<T>` (which has both\n * `[Symbol.asyncIterator]` and `.next()`). We verified at grep time\n * that no call sites use `.next()` on the scan result directly, so\n * the narrowed interface is safe.\n *\n * **Immutability:** each `.where()` / `.filter()` call returns a\n * fresh builder sharing the same page provider and page size. This\n * lets a base scan be reused for multiple parallel aggregations:\n *\n * ```ts\n * const scan = invoices.scan()\n * const [open, paid] = await Promise.all([\n * scan.where('status', '==', 'open').aggregate({ n: count() }),\n * scan.where('status', '==', 'paid').aggregate({ n: count() }),\n * ])\n * ```\n *\n * Note that each aggregation pays a full scan — there's no shared\n * iteration across the two. Multi-way aggregation in a single pass\n * is out of scope; consumers who need it should build a compound spec\n * and run a single `.aggregate({ openN, paidN })` at the DSL level.\n *\n * **Out of scope for (tracked separately):**\n * - `scan().aggregate().live()` — unbounded scan + change-stream\n * reconciliation is a design problem, not just a code one\n * - `scan().groupBy().aggregate()` — high-cardinality grouping on\n * huge collections would re-introduce the O(groups) memory\n * problem that aggregate fixes\n * - Parallel scan across pages — race-safe page cursor contracts\n * are not in the adapter API yet\n * - `scan().join(...)` — tracked under (streaming join)\n */\n\nimport type { Clause, FieldClause, Operator } from './predicate.js'\nimport { evaluateClause, readPath } from './predicate.js'\nimport type {\n AggregateSpec,\n AggregateResult,\n} from '../aggregate/aggregation.js'\nimport type { JoinContext, JoinLeg, JoinableSource } from './join.js'\nimport { DanglingReferenceError } from '../errors.js'\n\n/**\n * Page provider — the Collection-shaped hook the builder calls to\n * walk cursors forward. Kept as a structural interface so tests can\n * wire up a synthetic provider without pulling in the full\n * Collection class. Collection's `listPage` matches this shape\n * exactly.\n */\nexport interface ScanPageProvider<T> {\n listPage(opts: {\n cursor?: string\n limit?: number\n }): Promise<{ items: T[]; nextCursor: string | null }>\n}\n\nconst DEFAULT_SCAN_PAGE_SIZE = 100\n\n/**\n * Chainable streaming scan. Implements `AsyncIterable<T>` for\n * drop-in use with `for await … of`; adds `.where()` / `.filter()`\n * chainable clauses and a `.aggregate(spec)` async terminal.\n *\n * The builder is immutable per operation — each chained call\n * returns a fresh `ScanBuilder` sharing the same page provider and\n * page size. The original builder is never mutated, so it's safe\n * to reuse across multiple parallel consumers.\n */\nexport class ScanBuilder<T> implements AsyncIterable<T> {\n private readonly pageProvider: ScanPageProvider<T>\n private readonly pageSize: number\n private readonly clauses: readonly Clause[]\n /**\n * Zero-or-more join legs to apply per record as the stream flows.\n * Each leg attaches the resolved right-side record (or null) under\n * its alias. — streaming joins.\n *\n * Joins are evaluated AFTER clauses, so a `where()` filtered-out\n * record never triggers a right-side lookup. This is the same\n * ordering as `Query.toArray()` (clauses first, joins after) and\n * keeps the streaming path from doing wasted work.\n */\n private readonly joins: readonly JoinLeg[]\n /**\n * Join resolution context. Required for `.join()` to translate a\n * field name into a target collection + ref mode and to resolve\n * the right-side `JoinableSource`. Optional because tests\n * construct ScanBuilder directly with synthetic page providers\n * that don't know about ref() — calling `.join()` without a\n * context throws with an actionable error.\n */\n private readonly joinContext: JoinContext | undefined\n\n constructor(\n pageProvider: ScanPageProvider<T>,\n pageSize: number = DEFAULT_SCAN_PAGE_SIZE,\n clauses: readonly Clause[] = [],\n joins: readonly JoinLeg[] = [],\n joinContext?: JoinContext,\n ) {\n this.pageProvider = pageProvider\n this.pageSize = pageSize\n this.clauses = clauses\n this.joins = joins\n this.joinContext = joinContext\n }\n\n /**\n * Add a field comparison. Runs per record as the scan stream\n * flows through, so non-matching records are dropped before they\n * reach `.aggregate()` or the iteration consumer. Multiple\n * `.where()` calls are AND-combined — same semantics as\n * `Query.where()`.\n *\n * Clauses cannot use the secondary-index fast path here because\n * the scan sources records from the adapter's paginator, not from\n * the in-memory cache where indexes live. Index-accelerated scans\n * are a future optimization — the current implementation\n * evaluates clauses per record in O(1) per clause.\n */\n where(field: string, op: Operator, value: unknown): ScanBuilder<T> {\n const clause: FieldClause = { type: 'field', field, op, value }\n return new ScanBuilder<T>(\n this.pageProvider,\n this.pageSize,\n [...this.clauses, clause],\n this.joins,\n this.joinContext,\n )\n }\n\n /**\n * Escape hatch: add an arbitrary predicate function. Same\n * non-serializable caveat as `Query.filter()` — filter clauses\n * don't round-trip through `toPlan()`. Prefer `.where()` when\n * possible.\n */\n filter(fn: (record: T) => boolean): ScanBuilder<T> {\n const clause: Clause = {\n type: 'filter',\n fn: fn as (record: unknown) => boolean,\n }\n return new ScanBuilder<T>(\n this.pageProvider,\n this.pageSize,\n [...this.clauses, clause],\n this.joins,\n this.joinContext,\n )\n }\n\n /**\n * Resolve a `ref()`-declared foreign key per record as the scan\n * stream flows, attaching the right-side record (or null) under\n * `opts.as`. — streaming joins over `scan()`.\n *\n * ```ts\n * for await (const inv of invoices.scan().join('clientId', { as: 'client' })) {\n * await processInvoice(inv) // inv.client is attached\n * }\n *\n * // Or terminate with .aggregate() for streaming joined aggregation\n * const { total } = await invoices.scan()\n * .where('status', '==', 'open')\n * .join('clientId', { as: 'client' })\n * .aggregate({ total: sum('amount') })\n * ```\n *\n * **The key difference from eager `.join()`:** the LEFT\n * side streams page-by-page from the adapter and is never\n * materialized. Memory ceiling on the left is O(pageSize), not\n * O(rowCount). This is what makes streaming joins suitable for\n * collections that exceed the eager join's 50_000-row ceiling.\n *\n * **Right-side strategy** is auto-selected per leg:\n * - **Indexed** — right source exposes `lookupById`, so each\n * left row costs O(1). This is the common path for\n * Collection right sides, which back `lookupById` with a Map\n * lookup over the in-memory cache. The right collection must\n * be in eager mode (the same constraint as eager join's\n * `querySourceForJoin` from ).\n * - **Hash** — right source has only `snapshot()`. Build a\n * `Map<id, record>` once at iteration start, probe per left\n * row. Same correctness, same per-row cost as the indexed\n * path; the difference is the upfront cost of materializing\n * the right side once.\n *\n * Both strategies hold the right side in memory for the duration\n * of the iteration. The \"streaming\" property applies to the LEFT\n * side only — true left-and-right streaming joins (where neither\n * side fits in memory) require a sort-merge join planner that's\n * out of scope for.\n *\n * **Ref-mode semantics** match eager `.join()` exactly:\n * - `strict` → throws `DanglingReferenceError` mid-stream\n * when a left record points at a non-existent right id.\n * The throw aborts the async iterator — consumers should\n * wrap the `for await` in try/catch if they want to recover.\n * - `warn` → attaches `null` and emits a one-shot warning\n * per unique dangling pair (deduped via the same warn\n * channel as eager join).\n * - `cascade` → attaches `null` silently. A delete-time mode;\n * dangling refs at read time are mid-flight or pre-existing\n * orphans, not a DSL error.\n *\n * Left records with null/undefined FK values attach `null`\n * regardless of mode — same \"no reference at all\" policy as\n * eager join and write-time `enforceRefsOnPut`.\n *\n * **Multi-FK chaining** is supported via repeated `.join()`\n * calls: each leg resolves an independent ref. Each leg\n * independently picks its right-side strategy and applies its\n * own ref mode.\n *\n * **Joins are NOT applied** to a `.aggregate()` terminal that\n * doesn't reference joined fields — wait, that's not quite\n * right. The streaming path actually DOES apply joins before\n * `.aggregate()` because the join attaches a field that the\n * spec might reference. Unlike `Query.aggregate()` (which skips\n * joins entirely as a projection-only short-circuit), the\n * streaming aggregation can't know whether the spec touches a\n * joined field, so it always applies joins. Consumers who want\n * unjoined streaming aggregation should leave `.join()` off the\n * chain — the chain is composable for a reason.\n *\n * constraint #1 — every JoinLeg carries `partitionScope:\n * 'all'` plumbed through but never read by. Same seam as\n * eager join.\n */\n join<As extends string, R = unknown>(\n field: string,\n opts: { as: As },\n ): ScanBuilder<T & Record<As, R | null>> {\n if (!this.joinContext) {\n throw new Error(\n `ScanBuilder.join() requires a join context. Use ` +\n `collection.scan() to construct a join-capable scan instead ` +\n `of the ScanBuilder constructor directly (the direct ` +\n `constructor is only used for tests with synthetic page ` +\n `providers).`,\n )\n }\n const descriptor = this.joinContext.resolveRef(field)\n if (!descriptor) {\n throw new Error(\n `ScanBuilder.join(): no ref() declared for field \"${field}\" on ` +\n `collection \"${this.joinContext.leftCollection}\". Add ` +\n `refs: { ${field}: ref('<target-collection>') } to the ` +\n `collection options, then retry.`,\n )\n }\n const leg: JoinLeg = {\n field,\n as: opts.as,\n target: descriptor.target,\n mode: descriptor.mode,\n strategy: undefined,\n maxRows: undefined,\n // constraint #1 — always 'all' in, never read by\n // the streaming executor. partition-aware scan joins\n // will populate this from where() predicates without\n // changing the planner shape.\n partitionScope: 'all',\n }\n return new ScanBuilder<T & Record<As, R | null>>(\n this.pageProvider as unknown as ScanPageProvider<T & Record<As, R | null>>,\n this.pageSize,\n this.clauses,\n [...this.joins, leg],\n this.joinContext,\n )\n }\n\n /**\n * Iterate the scan as an async iterable. Walks the page\n * provider's cursors forward until exhaustion, applying every\n * clause per record — only matching records are yielded.\n *\n * Backward-compatible with the previous async-generator `scan()`\n * return type for `for await … of` consumers.\n */\n async *[Symbol.asyncIterator](): AsyncIterator<T> {\n // One-time setup: resolve every join leg's right-side source\n // and pick its strategy (lookupById per row vs hash from\n // snapshot once). Both are O(left) per record after setup; the\n // difference is the upfront cost of hashing the right side\n // when there's no lookupById.\n //\n // Hash maps live for the lifetime of the iteration, so memory\n // for the right side is O(rightRowCount) per leg. Memory for\n // the left side stays O(pageSize) regardless — that's the\n // streaming property we're after.\n const joinResolvers = this.joins.length === 0 ? null : this.buildJoinResolvers()\n\n let page = await this.pageProvider.listPage({ limit: this.pageSize })\n while (true) {\n for (const record of page.items) {\n if (!this.recordMatches(record)) continue\n if (joinResolvers === null) {\n yield record\n } else {\n // Apply every join leg in declaration order. Each\n // leg attaches a field — the result of one leg becomes\n // the input to the next. Multi-FK chaining is\n // supported by construction.\n let attached: unknown = record\n for (const resolver of joinResolvers) {\n attached = this.applyOneJoinStreaming(attached, resolver)\n }\n yield attached as T\n }\n }\n if (page.nextCursor === null) return\n page = await this.pageProvider.listPage({\n cursor: page.nextCursor,\n limit: this.pageSize,\n })\n }\n }\n\n /**\n * Per-leg right-side resolution state. Built once at iteration\n * start and reused for every left record. Two strategies:\n *\n * - `lookupById`: present when the right source exposes the\n * hook directly (typical Collection right side). Per-row\n * cost is O(1).\n * - `hashByPrimaryKey`: built from `snapshot()` when no\n * lookupById. Per-row cost is O(1) after the upfront O(N)\n * materialization. Same as eager join's hash strategy.\n *\n * `warnedKeys` is the per-leg dedup set for ref-mode 'warn'. We\n * key on `field→target:refId` so the same dangling pair only\n * warns once per iteration. The dedup is per-iteration, not\n * per-process — a long-running scan that re-iterates would warn\n * again, which is the desired behavior (the data may have\n * changed between iterations).\n */\n private buildJoinResolvers(): Array<{\n leg: JoinLeg\n source: JoinableSource\n lookupById: ((id: string) => unknown) | null\n hashByPrimaryKey: ReadonlyMap<string, unknown> | null\n warnedKeys: Set<string>\n }> {\n if (!this.joinContext) {\n // Unreachable — .join() throws if joinContext is missing.\n // Belt-and-braces because the iterator is invoked via\n // Symbol.asyncIterator on a builder that may have been\n // constructed via the direct constructor with pre-populated\n // joins.\n throw new Error(\n `ScanBuilder iterator: ${this.joins.length} join leg(s) ` +\n `present but no JoinContext attached. Use collection.scan() ` +\n `to construct a join-capable scan.`,\n )\n }\n const resolvers: Array<{\n leg: JoinLeg\n source: JoinableSource\n lookupById: ((id: string) => unknown) | null\n hashByPrimaryKey: ReadonlyMap<string, unknown> | null\n warnedKeys: Set<string>\n }> = []\n for (const leg of this.joins) {\n const source = this.joinContext.resolveSource(leg.target)\n if (!source) {\n throw new Error(\n `ScanBuilder.join() cannot resolve target collection ` +\n `\"${leg.target}\" (referenced from field \"${leg.field}\" on ` +\n `\"${this.joinContext.leftCollection}\"). Make sure the target ` +\n `collection has been opened via vault.collection() ` +\n `at least once before iterating the scan.`,\n )\n }\n // Strategy selection: prefer lookupById when available\n // (O(1) per row, no upfront cost), fall back to hashing\n // snapshot() once otherwise.\n let lookupById: ((id: string) => unknown) | null = null\n let hashByPrimaryKey: ReadonlyMap<string, unknown> | null = null\n if (source.lookupById) {\n // Bind through an arrow so the lookupById's `this`\n // doesn't drift — same pattern as the eager join's\n // strategy resolver.\n const fn = source.lookupById.bind(source)\n lookupById = (id: string): unknown => fn(id)\n } else {\n const map = new Map<string, unknown>()\n for (const record of source.snapshot()) {\n const rawId = readPath(record, 'id')\n const key = coerceRefKey(rawId)\n if (key !== null) map.set(key, record)\n }\n hashByPrimaryKey = map\n }\n resolvers.push({\n leg,\n source,\n lookupById,\n hashByPrimaryKey,\n warnedKeys: new Set<string>(),\n })\n }\n return resolvers\n }\n\n /**\n * Resolve a single join leg for one left record and return the\n * left record with the joined field attached under\n * `leg.as`. Pure function over `(left, resolver)`; never\n * mutates the input.\n *\n * Ref-mode dispatch matches eager `applyJoins` from :\n * - null/undefined FK → attach null silently (always allowed)\n * - dangling FK + strict → throw `DanglingReferenceError`\n * - dangling FK + warn → attach null, warn-once per pair\n * - dangling FK + cascade → attach null silently\n */\n private applyOneJoinStreaming(\n left: unknown,\n resolver: {\n leg: JoinLeg\n source: JoinableSource\n lookupById: ((id: string) => unknown) | null\n hashByPrimaryKey: ReadonlyMap<string, unknown> | null\n warnedKeys: Set<string>\n },\n ): unknown {\n if (left === null || typeof left !== 'object') {\n // Pathological input; matches eager join's defensive return.\n return left\n }\n const { leg } = resolver\n const rawId = readPath(left, leg.field)\n const refKey = coerceRefKey(rawId)\n let right: unknown = undefined\n if (refKey !== null) {\n if (resolver.lookupById !== null) {\n right = resolver.lookupById(refKey)\n } else if (resolver.hashByPrimaryKey !== null) {\n right = resolver.hashByPrimaryKey.get(refKey)\n }\n }\n\n const merged: Record<string, unknown> = {\n ...(left as Record<string, unknown>),\n }\n if (right === undefined) {\n // No matching record. Distinguish \"no ref at all\" (null FK)\n // from \"dangling ref\" (FK pointed at nothing).\n if (refKey !== null && leg.mode === 'strict') {\n throw new DanglingReferenceError({\n field: leg.field,\n target: leg.target,\n refId: refKey,\n message:\n `ScanBuilder.join() strict dangling: record references ` +\n `\"${leg.target}:${refKey}\" via field \"${leg.field}\", but no ` +\n `such record exists. Use ref() mode 'warn' or 'cascade' if ` +\n `dangling refs are acceptable, or run ` +\n `vault.checkIntegrity() to find and fix the orphans.`,\n })\n }\n if (refKey !== null && leg.mode === 'warn') {\n const dedupKey = `${leg.field}→${leg.target}:${refKey}`\n if (!resolver.warnedKeys.has(dedupKey)) {\n resolver.warnedKeys.add(dedupKey)\n console.warn(\n `[noy-db] ScanBuilder.join() encountered dangling ref in ` +\n `'warn' mode: field \"${leg.field}\" → \"${leg.target}:` +\n `${refKey}\" not found. Attaching null.`,\n )\n }\n }\n // strict already threw above; warn falls through here; cascade\n // hits this path silently.\n merged[leg.as] = null\n } else {\n merged[leg.as] = right\n }\n return merged\n }\n\n /**\n * Reduce the scan stream through a named set of reducers and\n * return the final aggregated shape.\n *\n * Memory is O(reducers): one mutable state slot per spec key.\n * Records flow through the pipeline one at a time via\n * `for await` and are discarded after their `step()` is applied\n * — never collected into an array. This is the distinguishing\n * property from `Query.aggregate()`, which materializes the full\n * match set first.\n *\n * Reuses the same reducer protocol as `Query.aggregate()`,\n * so `count()`, `sum(field)`, `avg(field)`, `min(field)`,\n * `max(field)` all work unchanged. The `{ seed }` parameter\n * plumbing from constraint #2 is honored transparently — the\n * factories ignore it in and the scan executor never\n * touches the per-reducer state construction.\n *\n * **Returns a Promise**, unlike `Query.aggregate().run()` which\n * is synchronous. The scan is inherently async because it walks\n * adapter pages, so the terminal has to be too. Consumers\n * destructure with await:\n *\n * ```ts\n * const { total, n } = await invoices.scan()\n * .where('year', '==', 2025)\n * .aggregate({ total: sum('amount'), n: count() })\n * ```\n *\n * **No `.live()` in.** `scan().aggregate().live()` would\n * require reconciling an unbounded streaming iteration with a\n * change-stream subscription — a design problem, not just a code\n * one. Consumers with huge collections and live needs should\n * narrow with `.where()` enough to fit in the 50k `query()`\n * limit and use `query().aggregate().live()` instead.\n */\n async aggregate<Spec extends AggregateSpec>(\n spec: Spec,\n ): Promise<AggregateResult<Spec>> {\n const keys = Object.keys(spec)\n // Per-reducer state. Exactly |keys| entries, never grows with\n // the record count — that's the O(reducers) memory guarantee.\n const state: Record<string, unknown> = {}\n for (const key of keys) {\n state[key] = spec[key]!.init()\n }\n\n // Record-by-record streaming step. `for await (… of this)`\n // invokes the Symbol.asyncIterator above, which honors the\n // clause list, so filtered-out records never reach the step\n // loop — they're dropped at the iterator boundary.\n for await (const record of this) {\n for (const key of keys) {\n state[key] = spec[key]!.step(state[key], record)\n }\n }\n\n const result: Record<string, unknown> = {}\n for (const key of keys) {\n result[key] = spec[key]!.finalize(state[key])\n }\n return result as AggregateResult<Spec>\n }\n\n /**\n * Evaluate the clause list against a single record. Linear in\n * the clause count; short-circuits on first false. Clauses on a\n * scan are always re-evaluated per record — no index-accelerated\n * path, because the stream sources records from the adapter\n * paginator, not from the in-memory cache where indexes live.\n */\n private recordMatches(record: T): boolean {\n if (this.clauses.length === 0) return true\n for (const clause of this.clauses) {\n if (!evaluateClause(record, clause)) return false\n }\n return true\n }\n}\n\n/**\n * Coerce an unknown FK value into a lookup key string.\n *\n * Mirror of the same helper in `query/join.ts` — kept local to\n * `scan-builder.ts` to avoid pulling the eager join executor's\n * surface area into this file. Strings and numbers convert to\n * string keys; everything else (objects, arrays, booleans, null,\n * undefined) returns null and is treated as \"no ref at all\".\n *\n * Matches the write-time `enforceRefsOnPut` policy: nullish ref\n * values are never dangling, regardless of mode.\n */\nfunction coerceRefKey(value: unknown): string | null {\n if (value === null || value === undefined) return null\n if (typeof value === 'string') return value\n if (typeof value === 'number' || typeof value === 'bigint') return String(value)\n return null\n}\n"],"mappings":"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","names":["coerceRefKey"]}
@@ -4,13 +4,13 @@ import {
4
4
  import {
5
5
  base64ToBuffer,
6
6
  bufferToBase64
7
- } from "./chunk-UOF74WQY.js";
7
+ } from "./chunk-EKTOYEZ3.js";
8
8
  import {
9
9
  SessionExpiredError,
10
10
  SessionNotFoundError,
11
11
  SessionPolicyError,
12
12
  ValidationError
13
- } from "./chunk-YDLAFP36.js";
13
+ } from "./chunk-6HJ2ZALB.js";
14
14
 
15
15
  // src/session/session.ts
16
16
  var subtle = globalThis.crypto.subtle;
@@ -364,4 +364,4 @@ export {
364
364
  clearDevUnlock,
365
365
  isDevUnlockActive
366
366
  };
367
- //# sourceMappingURL=chunk-QAU5HM6Q.js.map
367
+ //# sourceMappingURL=chunk-XVJFFGTG.js.map
@@ -1,15 +1,15 @@
1
1
  import {
2
2
  dekKey
3
- } from "./chunk-6S3LLAQ5.js";
3
+ } from "./chunk-TNBIWSQ7.js";
4
4
  import {
5
5
  assertStrongPassphrase,
6
6
  mintKeyringCanary,
7
7
  persistKeyring
8
- } from "./chunk-TLFUDXVV.js";
8
+ } from "./chunk-T6MTNGBM.js";
9
9
  import {
10
10
  NOYDB_FORMAT_VERSION,
11
11
  NOYDB_KEYRING_VERSION
12
- } from "./chunk-FXQYZNOW.js";
12
+ } from "./chunk-5OVIFUQE.js";
13
13
  import {
14
14
  base64ToBuffer,
15
15
  bufferToBase64,
@@ -19,7 +19,7 @@ import {
19
19
  generateSalt,
20
20
  unwrapKey,
21
21
  wrapKey
22
- } from "./chunk-UOF74WQY.js";
22
+ } from "./chunk-EKTOYEZ3.js";
23
23
  import {
24
24
  DelegationTargetMissingError,
25
25
  InvalidKeyError,
@@ -28,7 +28,7 @@ import {
28
28
  PermissionDeniedError,
29
29
  PrivilegeEscalationError,
30
30
  ValidationError
31
- } from "./chunk-YDLAFP36.js";
31
+ } from "./chunk-6HJ2ZALB.js";
32
32
 
33
33
  // src/team/authenticators.ts
34
34
  async function enrollAuthenticator(store, vault, keyring, options) {
@@ -827,4 +827,4 @@ export {
827
827
  magicLinkGrantRecordId,
828
828
  isMagicLinkGrantExpired
829
829
  };
830
- //# sourceMappingURL=chunk-2EYC3WDT.js.map
830
+ //# sourceMappingURL=chunk-Y3P5DEMZ.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../src/team/authenticators.ts","../src/policy/errors.ts","../src/team/wrapped-deks.ts","../src/team/recovery.ts","../src/team/rotate-recover.ts","../src/team/peer-recover.ts","../src/team/magic-link-grant.ts"],"sourcesContent":["/**\n * Tier-2 authenticator slot management.\n *\n * Each slot independently wraps the SAME KEK under a method-specific\n * derived key (LUKS pattern). Enrolling adds a slot; removing drops\n * one. Both are constant-time keyring writes — no DEK re-keying.\n *\n * The crypto for each method lives in its `@noy-db/on-*` package\n * (`on-webauthn`, `on-oidc`, `on-password`); this module accepts the\n * package's `wrapped_kek` ciphertext + `meta` payload and persists it.\n *\n * @see docs/subsystems/session-tiers.md → Tier 2 — Authenticate\n *\n * @module\n */\nimport type { NoydbStore, KeyringAuthenticator } from '../types.js'\nimport { NoAccessError, ValidationError } from '../errors.js'\nimport type { UnlockedKeyring } from './keyring.js'\nimport { persistKeyring } from './keyring.js'\n\n/** Fields shared across both wrap-KEK and wrap-DEKs enroll inputs. */\ninterface EnrollAuthenticatorBase {\n readonly id: string\n readonly method: KeyringAuthenticator['method']\n /** Method-specific metadata (cred id, salt, …). */\n readonly meta: Record<string, unknown>\n /** Tier the active session held when enrolling. Defaults to 1. */\n readonly enrolled_via_tier?: 1 | 2\n}\n\n/** Wrap-KEK enroll input (WebAuthn, OIDC). */\nexport interface EnrollAuthenticatorWrappingKEKOptions extends EnrollAuthenticatorBase {\n /** Already-wrapped KEK ciphertext (base64) — produced by the on-* package. */\n readonly wrapped_kek: string\n readonly wrapKind?: 'kek'\n}\n\n/** Wrap-DEKs enroll input (password, future on-* using the unified wrap-DEKs primitive). */\nexport interface EnrollAuthenticatorWrappingDEKsOptions extends EnrollAuthenticatorBase {\n readonly wrapKind: 'deks'\n /** Base64 AES-GCM ciphertext of `{ deks: { collection: base64rawDek } }`. */\n readonly wrapped_deks: string\n /** Base64 AES-GCM IV used for the `wrapped_deks` ciphertext. */\n readonly iv: string\n}\n\n/** Discriminated union over the two enroll input shapes. */\nexport type EnrollAuthenticatorOptions =\n | EnrollAuthenticatorWrappingKEKOptions\n | EnrollAuthenticatorWrappingDEKsOptions\n\n/**\n * Append a new authenticator slot to the keyring file. Throws\n * `ValidationError` if a slot with the same id already exists — the\n * caller decides whether to remove + re-enroll.\n *\n * Accepts either wrap-KEK (WebAuthn, OIDC) or wrap-DEKs (password)\n * input. The variant is preserved verbatim into `KeyringAuthenticator`.\n */\nexport async function enrollAuthenticator(\n store: NoydbStore,\n vault: string,\n keyring: UnlockedKeyring,\n options: EnrollAuthenticatorOptions,\n): Promise<UnlockedKeyring> {\n const existing = keyring.authenticators.find((a) => a.id === options.id)\n if (existing) {\n throw new ValidationError(\n `enrollAuthenticator: slot id \"${options.id}\" already exists in vault \"${vault}\". ` +\n 'Remove the slot first or pick a unique id.',\n )\n }\n\n const base = {\n id: options.id,\n method: options.method,\n enrolled_at: new Date().toISOString(),\n enrolled_via_tier: options.enrolled_via_tier ?? 1,\n meta: options.meta,\n } as const\n\n const slot: KeyringAuthenticator = options.wrapKind === 'deks'\n ? {\n ...base,\n wrapKind: 'deks',\n wrapped_deks: options.wrapped_deks,\n iv: options.iv,\n }\n : {\n ...base,\n wrapped_kek: options.wrapped_kek,\n }\n\n const next = appendSlot(keyring, slot)\n await persistKeyring(store, vault, next)\n return next\n}\n\n/**\n * Caller payload for {@link updateAuthenticator}. Mutates only\n * `meta` — the slot's id, method, and wrap material are immutable\n * through this primitive, preserving the anti-slot-swap guard.\n *\n * `meta` is **merged** at the top level: keys absent from the patch\n * are preserved, keys present overwrite. To clear a meta key, pass\n * `null` for that key explicitly. (Same top-level merge semantics as\n * `UserApi.updateMe`, non-recursive — meta is a flat label bag.)\n */\nexport interface UpdateAuthenticatorOptions {\n readonly meta?: Record<string, unknown>\n}\n\n/**\n * Mutate a tier-2 authenticator slot's `meta` blob (slot rename,\n * label changes). The slot's `id`, `method`, and wrap material\n * (`wrapped_kek` for wrap-KEK; `wrapped_deks` + `iv` for wrap-DEKs)\n * are immutable through this entry point — the anti-slot-swap guard\n * is structural, not gate-driven, so even if the policy gate is\n * weakened a future caller cannot use this path to swap one slot's\n * crypto for another's.\n *\n * `meta` patch semantics:\n * - Top-level merge — absent keys preserved, present keys overwrite\n * - `null` value — delete that meta key\n * - Non-object values (string, number, boolean, array) — replace verbatim\n *\n * @throws `NoAccessError` when no slot with the given id exists.\n * @throws `ValidationError` when no patch field is provided.\n *\n */\nexport async function updateAuthenticator(\n store: NoydbStore,\n vault: string,\n keyring: UnlockedKeyring,\n slotId: string,\n options: UpdateAuthenticatorOptions,\n): Promise<UnlockedKeyring> {\n if (options.meta === undefined) {\n throw new ValidationError(\n `updateAuthenticator: at least one of meta must be provided ` +\n `(slotId: \"${slotId}\").`,\n )\n }\n\n const idx = keyring.authenticators.findIndex((a) => a.id === slotId)\n if (idx === -1) {\n throw new NoAccessError(\n `updateAuthenticator: slot \"${slotId}\" not found in vault \"${vault}\".`,\n )\n }\n const existing = keyring.authenticators[idx]!\n\n // Merge at the top level. Absent keys preserved (non-recursive —\n // meta is a flat label bag in practice, no consumer nests it).\n const mergedMeta: Record<string, unknown> = { ...existing.meta }\n for (const [k, v] of Object.entries(options.meta)) {\n if (v === undefined) continue // skip\n if (v === null) {\n delete mergedMeta[k]\n continue\n }\n mergedMeta[k] = v\n }\n\n // Reconstruct the slot preserving wrapKind discrimination. The\n // immutable fields (id, method, wrapped_kek / wrapped_deks + iv,\n // enrolled_at, enrolled_via_tier) all flow through ...existing.\n const next: KeyringAuthenticator = { ...existing, meta: mergedMeta }\n const nextSlots = [...keyring.authenticators]\n nextSlots[idx] = next\n\n const nextKeyring: UnlockedKeyring = {\n ...keyring,\n authenticators: nextSlots,\n }\n await persistKeyring(store, vault, nextKeyring)\n return nextKeyring\n}\n\n/**\n * Drop a slot by id. No-op if the slot doesn't exist (idempotent —\n * removing a non-existent slot is a recoverable retry, not an error).\n */\nexport async function removeAuthenticator(\n store: NoydbStore,\n vault: string,\n keyring: UnlockedKeyring,\n slotId: string,\n): Promise<UnlockedKeyring> {\n const filtered = keyring.authenticators.filter((a) => a.id !== slotId)\n if (filtered.length === keyring.authenticators.length) {\n return keyring // idempotent — nothing to do\n }\n const next: UnlockedKeyring = {\n ...keyring,\n authenticators: filtered,\n }\n await persistKeyring(store, vault, next)\n return next\n}\n\n/**\n * Look up a slot by id. Returns `undefined` when no slot matches.\n * Used by tier-2 unlock dispatchers to fetch the wrapped KEK + meta\n * before invoking the method-specific verifier.\n */\nexport function findAuthenticator(\n keyring: UnlockedKeyring,\n slotId: string,\n): KeyringAuthenticator | undefined {\n return keyring.authenticators.find((a) => a.id === slotId)\n}\n\nfunction appendSlot(\n keyring: UnlockedKeyring,\n slot: KeyringAuthenticator,\n): UnlockedKeyring {\n return {\n ...keyring,\n authenticators: [...keyring.authenticators, slot],\n }\n}\n","import { NoydbError } from '../errors.js'\nimport type { GateName, GatePolicy } from './types.js'\n\n/**\n * Why a gate denied a request. Stable across hub versions so consumers\n * can switch on the value in error UIs.\n */\nexport type PolicyDenyReason =\n | 'insufficient-tier'\n | 'missing-factor'\n | 'stale-proof'\n | 'disabled'\n | 'shared-device-blocked'\n\n/**\n * Thrown by {@link checkGate} when the active session does not meet\n * the gate's requirements. Carries the gate name, the reason, and the\n * full required {@link GatePolicy} so error UIs can prompt the user\n * for the missing factor without re-reading the policy document.\n */\nexport class PolicyDeniedError extends NoydbError {\n readonly gate: GateName\n readonly reason: PolicyDenyReason\n readonly required: GatePolicy\n constructor(gate: GateName, reason: PolicyDenyReason, required: GatePolicy, message?: string) {\n super(\n 'POLICY_DENIED',\n message ?? `Gate \"${gate}\" denied: ${reason}.`,\n )\n this.name = 'PolicyDeniedError'\n this.gate = gate\n this.reason = reason\n this.required = required\n }\n}\n\n/**\n * Raised by `createNoydb({ ... })` when the developer omits a recovery\n * profile and `recover-passphrase` is not explicitly disabled. Vaults\n * MUST have at least one recovery path enrolled before being\n * production-ready (paper, shamir, multi-channel, or admin-mediated).\n *\n * The error message carries a pointer to the recovery design docs.\n */\nexport class RecoveryNotEnrolledError extends NoydbError {\n constructor(\n message =\n 'Recovery profile not enrolled. Pass `recovery: [{ profile: \"paper\", codes: 10 }]` ' +\n 'to `createNoydb()`, or set `policy.gates[\"recover-passphrase\"].enabled = false` to ' +\n 'opt out of recovery (passphrase loss = data loss). See docs/subsystems/session-tiers.md.',\n ) {\n super('RECOVERY_NOT_ENROLLED', message)\n this.name = 'RecoveryNotEnrolledError'\n }\n}\n\n/**\n * Raised by `openVault` when a managed-passphrase-mode vault has no\n * STRONG recovery profile enrolled.\n *\n * Managed mode means the user never types a passphrase — the unlock\n * material lives in a `SealingKeyProvider` (`at-*` package). If that\n * provider's key is lost AND no strong recovery is enrolled, the\n * vault is irrecoverable. To prevent that footgun, managed-mode vaults\n * require at least one strong recovery profile (Shamir today;\n * multi-channel / admin-mediated when those ship).\n *\n * Paper recovery alone is NOT strong under managed mode: the user has\n * no memorized passphrase to fall back on, so losing the paper sheet =\n * losing every record permanently.\n *\n * Bootstrap with `db.openVaultAndEnrollRecovery(vault, { recovery: [{ profile: \"shamir\", k, n }] })`\n * to atomically create-and-enroll, or call `db.enrollRecovery(vault, { profile: \"shamir\", ... })`\n * separately before re-attempting `openVault`.\n */\nexport class ManagedRecoveryNotEnrolledError extends NoydbError {\n readonly vault: string\n constructor(vault: string) {\n super(\n 'MANAGED_RECOVERY_NOT_ENROLLED',\n `Managed-mode vault \"${vault}\" requires at least one strong recovery profile `\n + '(Shamir today; multi-channel / admin-mediated when they ship). Paper alone is '\n + 'NOT strong under managed mode — losing the paper sheet would mean losing every '\n + 'record permanently. '\n + `Bootstrap with \\`db.openVaultAndEnrollRecovery(\"${vault}\", { recovery: [{ profile: \"shamir\", k: 2, n: 3 }] })\\`, `\n + 'or call `db.enrollRecovery(vault, { profile: \"shamir\", k, n })` separately, '\n + 'then re-attempt `openVault`.',\n )\n this.name = 'ManagedRecoveryNotEnrolledError'\n this.vault = vault\n }\n}\n\n/**\n * Raised by `db.recoverPassphrase` / `db.enrollRecovery` /\n * `db.rotateRecovery` when the developer requests a recovery profile\n * not yet wired in this hub release.\n *\n * Implemented: `paper` and `shamir`.\n * Pending: `multi-channel` and `admin-mediated` (follow-up slices).\n *\n * The carried `profile` and `tracking` fields let consumers steer the\n * UI (\"multi-channel recovery is not yet wired up — open issue #N to follow\").\n */\nexport class RecoveryProfileNotImplementedError extends NoydbError {\n readonly profile: string\n readonly tracking: string\n constructor(profile: string, tracking: string) {\n super(\n 'RECOVERY_PROFILE_NOT_IMPLEMENTED',\n `Recovery profile \"${profile}\" is not yet implemented in this hub release. ` +\n `Tracking: ${tracking}. Use the \"paper\" profile via @noy-db/on-recovery in the meantime.`,\n )\n this.name = 'RecoveryProfileNotImplementedError'\n this.profile = profile\n this.tracking = tracking\n }\n}\n","/**\n * **Wrap-DEKs primitive** — a single canonical shape for the\n * pattern of \"serialize a DEK set, encrypt it under a credential-derived\n * AES-GCM key.\" Used by:\n *\n * - **tier-0** — paper recovery entries (`_meta/recovery-paper`),\n * credential = the printed code.\n * - **tier-2** — password authenticator slots (`KeyringFile.authenticators`,\n * `wrapKind: 'deks'`), credential = the user's password.\n *\n * **Not** used by `@noy-db/on-pin` — tier-3 wraps the DEK set under\n * the same conceptual pattern but at **100,000 PBKDF2 iterations**\n * (vs the 600,000 here), because the protection window for a PIN\n * slot is short (idle-timeout-bounded, typically 15 min) and 600k\n * iterations would make every PIN-resume noticeably slow. The wire\n * formats are deliberately incompatible. See `@noy-db/on-pin`'s\n * `PIN_PBKDF2_ITERATIONS` and the threat-model rationale in its\n * module docstring.\n *\n * Previously, the same crypto lived in two places: `mintPaperRecoveryEntry`\n * (in `team/recovery.ts`) and `enrollPasswordAuthenticator` (in\n * `@noy-db/on-password`). Both functions did identical work — PBKDF2\n * the credential, AES-GCM-encrypt the JSON-serialized DEK set — but\n * their implementations had drifted apart enough that fixing a bug\n * in one wouldn't fix the other.\n *\n * This module owns the canonical implementation. Consumers compose:\n *\n * - `mintPaperRecoveryEntry` is now a thin wrapper that calls\n * `mintWrappedDeksBlob` and adds `{ codeId, enrolledAt }`.\n * - `enrollPasswordAuthenticator` calls `mintWrappedDeksBlob` and\n * wraps the result in the slot envelope.\n *\n * @module\n */\n\nconst PBKDF2_ITERATIONS = 600_000\nconst SALT_BYTES = 32\nconst IV_BYTES = 12\n\nconst subtle = globalThis.crypto.subtle\n\n// ─── Type ──────────────────────────────────────────────────────────────\n\n/**\n * The wrap-DEKs primitive — a serialized + AES-GCM-encrypted DEK set\n * keyed under a credential-derived key.\n *\n * All three fields are base64-encoded so the blob is JSON-safe and\n * round-trips through `_meta/*` envelopes (which carry plaintext\n * JSON in `_data`).\n *\n * Composition: `PaperRecoveryEntry extends WrappedDeksBlob` plus\n * `{ codeId, enrolledAt }`. `KeyringAuthenticatorWrappingDEKs`\n * carries the same three fields with `salt` stored in `meta` for\n * slot-format back-compat (defers moving it to top-level).\n */\nexport interface WrappedDeksBlob {\n /** Base64 PBKDF2 salt for the credential-derived wrapping key. */\n readonly salt: string\n /** Base64 AES-GCM IV used for the `wrappedDeks` ciphertext. */\n readonly iv: string\n /** Base64 AES-GCM ciphertext of `{ deks: { collection: base64rawDek } }`. */\n readonly wrappedDeks: string\n}\n\n// ─── Mint ──────────────────────────────────────────────────────────────\n\n/**\n * Mint a fresh `WrappedDeksBlob` from a DEK set + a string credential.\n *\n * Generates a random salt + IV, derives a 256-bit AES-GCM key via\n * PBKDF2-SHA256(credential, salt, 600K), serializes the DEK set as\n * `{ deks: { coll: rawBase64 } }`, and AES-GCM-encrypts.\n *\n * The `credential` is the user-typed string (recovery code, password,\n * PIN). Caller normalization rules apply (e.g. paper\n * recovery uppercase-strips the code before reaching this function).\n *\n * @param deks - DEK set to wrap. Each DEK must be exportable via\n * `subtle.exportKey('raw', dek)` (the hub mints DEKs\n * this way; consumers feeding non-extractable keys\n * will get `InvalidAccessError` from WebCrypto).\n * @param credential - String input the consumer minted (paper code,\n * password, PIN). Treated as opaque bytes by PBKDF2.\n */\nexport async function mintWrappedDeksBlob(\n deks: Map<string, CryptoKey>,\n credential: string,\n): Promise<WrappedDeksBlob> {\n const salt = crypto.getRandomValues(new Uint8Array(SALT_BYTES))\n const iv = crypto.getRandomValues(new Uint8Array(IV_BYTES))\n const wrappingKey = await deriveWrappingKey(credential, salt)\n\n // Serialize the DEK set as JSON `{ deks: { collection: base64 } }`.\n const exported: Record<string, string> = {}\n for (const [coll, dek] of deks) {\n const raw = await subtle.exportKey('raw', dek)\n exported[coll] = bytesToBase64(new Uint8Array(raw))\n }\n const plaintext = new TextEncoder().encode(JSON.stringify({ deks: exported }))\n const ciphertext = await subtle.encrypt(\n { name: 'AES-GCM', iv: iv as BufferSource },\n wrappingKey,\n plaintext as BufferSource,\n )\n\n return {\n salt: bytesToBase64(salt),\n iv: bytesToBase64(iv),\n wrappedDeks: bytesToBase64(new Uint8Array(ciphertext)),\n }\n}\n\n// ─── Unwrap ────────────────────────────────────────────────────────────\n\n/**\n * Reverse of {@link mintWrappedDeksBlob}. Re-derives the wrapping key\n * from the credential + stored salt, AES-GCM-decrypts the wrapped DEK\n * set, and re-imports each DEK as an extractable AES-GCM CryptoKey.\n *\n * Throws (AES-GCM auth tag failure) when the credential doesn't\n * match the blob. Callers iterating over multiple blobs (e.g. paper\n * recovery's \"try every entry until one matches\") should catch.\n */\nexport async function unwrapDeksFromBlob(\n blob: WrappedDeksBlob,\n credential: string,\n): Promise<Map<string, CryptoKey>> {\n const wrappingKey = await deriveWrappingKey(credential, base64ToBytes(blob.salt))\n const plaintext = await subtle.decrypt(\n { name: 'AES-GCM', iv: base64ToBytes(blob.iv) as BufferSource },\n wrappingKey,\n base64ToBytes(blob.wrappedDeks) as BufferSource,\n )\n const parsed = JSON.parse(new TextDecoder().decode(plaintext)) as { deks: Record<string, string> }\n const deks = new Map<string, CryptoKey>()\n for (const [coll, b64] of Object.entries(parsed.deks)) {\n const raw = base64ToBytes(b64)\n const key = await subtle.importKey(\n 'raw',\n raw as BufferSource,\n { name: 'AES-GCM', length: 256 },\n true,\n ['encrypt', 'decrypt'],\n )\n deks.set(coll, key)\n }\n return deks\n}\n\n// ─── Internals ─────────────────────────────────────────────────────────\n\nasync function deriveWrappingKey(credential: string, salt: Uint8Array): Promise<CryptoKey> {\n const ikm = await subtle.importKey(\n 'raw',\n new TextEncoder().encode(credential),\n 'PBKDF2',\n false,\n ['deriveKey'],\n )\n return subtle.deriveKey(\n {\n name: 'PBKDF2',\n salt: salt as BufferSource,\n iterations: PBKDF2_ITERATIONS,\n hash: 'SHA-256',\n },\n ikm,\n { name: 'AES-GCM', length: 256 },\n false,\n ['encrypt', 'decrypt'],\n )\n}\n\nfunction bytesToBase64(b: Uint8Array): string {\n let s = ''\n for (const x of b) s += String.fromCharCode(x)\n return btoa(s)\n}\n\nfunction base64ToBytes(b64: string): Uint8Array {\n const s = atob(b64)\n const out = new Uint8Array(s.length)\n for (let i = 0; i < s.length; i++) out[i] = s.charCodeAt(i)\n return out\n}\n","/**\n * Recovery profile persistence + dispatch.\n *\n * Wires the **paper** profile end-to-end through\n * `@noy-db/on-recovery`. The other three profiles (Shamir,\n * multi-channel, admin-mediated) ship the API surface and throw\n * {@link RecoveryProfileNotImplementedError} during use; per-profile\n * dispatch lands in follow-up issues.\n *\n * Storage layout:\n *\n * ```\n * _meta/recovery-paper — JSON { entries: RecoveryCodeEntry[] } produced by `on-recovery`.\n * _meta/recovery-shamir — reserved\n * _meta/recovery-multi — reserved\n * _meta/recovery-admin — reserved\n * ```\n *\n * Like `_meta/policy` and `_meta/handle`, the documents are plain JSON\n * with empty `_iv` — the recovery-code wrapping is what protects the\n * KEK; the entries themselves are inert without the user's code.\n *\n * @module\n */\nimport type { NoydbStore, EncryptedEnvelope } from '../types.js'\nimport { NOYDB_FORMAT_VERSION } from '../types.js'\nimport {\n mintWrappedDeksBlob,\n unwrapDeksFromBlob,\n type WrappedDeksBlob,\n} from './wrapped-deks.js'\nimport type { ShamirRecoveryProvider } from './shamir-recovery-provider.js'\n\n/**\n * One paper recovery code as persisted in `_meta/recovery-paper`.\n *\n * The hub's KEK is intentionally non-extractable (see `crypto.ts`),\n * so the recovery entry can't AES-KW-wrap the KEK directly. Instead\n * we wrap a serialized DEK set: the entry holds the AES-GCM\n * ciphertext of `{ deks: { collection: rawDekBase64 } }`. Recovery\n * deserializes the DEK set, then mints a fresh KEK from the new\n * passphrase and rewraps the DEKs under it.\n *\n * This is the same pattern `@noy-db/on-pin` uses for tier-3 quick\n * resume — the cryptographic guarantee is identical (AES-GCM with a\n * PBKDF2-derived key), and it sidesteps the non-extractable-KEK\n * constraint cleanly.\n *\n * Type-level composition: `PaperRecoveryEntry extends\n * WrappedDeksBlob` — the three crypto fields (`salt`, `iv`,\n * `wrappedDeks`) come from the shared primitive; `codeId` and\n * `enrolledAt` are paper-recovery's own metadata. Wire format\n * unchanged.\n */\nexport interface PaperRecoveryEntry extends WrappedDeksBlob {\n readonly codeId: string\n readonly enrolledAt: string\n}\n\nexport interface PaperRecoveryDoc {\n readonly _noydb_recovery: 1\n readonly profile: 'paper'\n readonly entries: ReadonlyArray<PaperRecoveryEntry>\n}\n\nconst PAPER_DOC_ID = 'recovery-paper'\n\n/** Read the paper-recovery entries. Returns empty array when absent. */\nexport async function loadPaperRecoveryEntries(\n store: NoydbStore,\n vault: string,\n): Promise<ReadonlyArray<PaperRecoveryEntry>> {\n const env = await store.get(vault, '_meta', PAPER_DOC_ID)\n if (!env) return []\n try {\n const doc = JSON.parse(env._data) as PaperRecoveryDoc\n if (doc.profile !== 'paper' || !Array.isArray(doc.entries)) return []\n return doc.entries\n } catch {\n return []\n }\n}\n\n/** Replace the paper-recovery entries (used after burn-on-recovery). */\nexport async function savePaperRecoveryEntries(\n store: NoydbStore,\n vault: string,\n entries: ReadonlyArray<PaperRecoveryEntry>,\n): Promise<void> {\n const doc: PaperRecoveryDoc = {\n _noydb_recovery: 1,\n profile: 'paper',\n entries,\n }\n const envelope: EncryptedEnvelope = {\n _noydb: NOYDB_FORMAT_VERSION,\n _v: 1,\n _ts: new Date().toISOString(),\n _iv: '',\n _data: JSON.stringify(doc),\n }\n await store.put(vault, '_meta', PAPER_DOC_ID, envelope)\n}\n\n/** Drop a single paper-recovery entry (burn-on-use). */\nexport async function burnPaperRecoveryEntry(\n store: NoydbStore,\n vault: string,\n codeId: string,\n): Promise<void> {\n const entries = await loadPaperRecoveryEntries(store, vault)\n const remaining = entries.filter((e) => e.codeId !== codeId)\n await savePaperRecoveryEntries(store, vault, remaining)\n}\n\n/** Whether at least one recovery profile has any enrolled entries. */\nexport async function hasRecoveryEnrolled(\n store: NoydbStore,\n vault: string,\n): Promise<boolean> {\n const paper = await loadPaperRecoveryEntries(store, vault)\n if (paper.length > 0) return true\n const shamir = await loadShamirRecoveryEntries(store, vault)\n return shamir.length > 0\n}\n\n/**\n * Whether at least one **strong** recovery profile is enrolled.\n *\n * \"Strong\" excludes paper-alone — under managed-passphrase mode the\n * user has no memorized passphrase, so a stolen/lost paper sheet\n * would be a single point of total loss. Strong profiles today:\n *\n * - `shamir` (k-of-n threshold; survives loss of up to n-k shares)\n * - `multi-channel` (when shipped — follow-up slice)\n * - `admin-mediated` (when shipped — follow-up slice)\n *\n * Managed mode requires this check to pass before `openVault` returns.\n */\nexport async function hasStrongRecoveryEnrolled(\n store: NoydbStore,\n vault: string,\n): Promise<boolean> {\n const shamir = await loadShamirRecoveryEntries(store, vault)\n return shamir.length > 0\n // When multi-channel / admin-mediated land, extend this check.\n}\n\n// ─── Shamir recovery ─────────────────────────────────────────────────────\n\n/**\n * One Shamir-recovery entry as persisted in `_meta/recovery-shamir`.\n *\n * Like {@link PaperRecoveryEntry}, the entry composes\n * {@link WrappedDeksBlob} (DEKs wrapped under a fresh ephemeral\n * recovery secret) with profile-specific metadata. Unlike paper, the\n * \"credential\" was never visible to the user — it was 32 random\n * bytes split into N Shamir shares at enrollment. The shares ARE\n * the credential; the user holds them, the hub never sees them\n * again after `enrollRecovery` returns.\n *\n * Per the spec §5: the recovery secret is base64-encoded and\n * passed as the `credential` arg to\n * {@link mintWrappedDeksBlob} / {@link unwrapDeksFromBlob}. The\n * PBKDF2 round over high-entropy input is harmless overhead — it\n * keeps the shared primitive unchanged while letting Shamir reuse\n * the same wrapping pipeline as paper.\n */\nexport interface ShamirRecoveryEntry extends WrappedDeksBlob {\n /** Stable id for this entry. Allows multiple Shamir splits to coexist. */\n readonly entryId: string\n /** Threshold — minimum shares to reconstruct. */\n readonly k: number\n /** Total shares minted at enrollment. */\n readonly n: number\n /** x-coordinates of the n minted shares. Informational. Omitted as of 0.2\n * (string-level provider doesn't expose share x-coords); kept optional so\n * pre-0.2 entries still read. */\n readonly xCoords?: ReadonlyArray<number>\n /** ISO timestamp. */\n readonly enrolledAt: string\n /** Optional caller-supplied label (e.g., \"2-of-3 board escrow\"). */\n readonly label?: string\n}\n\nexport interface ShamirRecoveryDoc {\n readonly _noydb_recovery: 1\n readonly profile: 'shamir'\n readonly entries: ReadonlyArray<ShamirRecoveryEntry>\n}\n\nconst SHAMIR_DOC_ID = 'recovery-shamir'\n\n/** Read the Shamir-recovery entries. Returns empty array when absent. */\nexport async function loadShamirRecoveryEntries(\n store: NoydbStore,\n vault: string,\n): Promise<ReadonlyArray<ShamirRecoveryEntry>> {\n const env = await store.get(vault, '_meta', SHAMIR_DOC_ID)\n if (!env) return []\n try {\n const doc = JSON.parse(env._data) as ShamirRecoveryDoc\n if (doc.profile !== 'shamir' || !Array.isArray(doc.entries)) return []\n return doc.entries\n } catch {\n return []\n }\n}\n\n/** Replace the Shamir-recovery entries (used by enrollment and rotation). */\nexport async function saveShamirRecoveryEntries(\n store: NoydbStore,\n vault: string,\n entries: ReadonlyArray<ShamirRecoveryEntry>,\n): Promise<void> {\n const doc: ShamirRecoveryDoc = {\n _noydb_recovery: 1,\n profile: 'shamir',\n entries,\n }\n const envelope: EncryptedEnvelope = {\n _noydb: NOYDB_FORMAT_VERSION,\n _v: 1,\n _ts: new Date().toISOString(),\n _iv: '',\n _data: JSON.stringify(doc),\n }\n await store.put(vault, '_meta', SHAMIR_DOC_ID, envelope)\n}\n\n/**\n * Mint a fresh Shamir recovery entry from a DEK set.\n *\n * 1. Generates a 32-byte recovery secret.\n * 2. Wraps the DEK set under that secret via\n * {@link mintWrappedDeksBlob} (the recovery secret is base64-\n * encoded as the credential string — PBKDF2 over high-entropy\n * input is harmless overhead).\n * 3. Splits the recovery secret via Shamir into `n` shares with\n * threshold `k`.\n * 4. Zeros the in-memory recovery secret after wrapping + splitting.\n *\n * Returns:\n * - `entry` — the {@link ShamirRecoveryEntry} to persist.\n * - `shareStrings` — the `n` Base32-encoded share strings to\n * return to the caller. The HUB MUST NOT PERSIST THESE; once\n * returned they are the user's responsibility.\n *\n * @param deks - DEK set to wrap.\n * @param entryId - Stable id for this entry (caller-supplied or\n * hub-generated).\n * @param k - Threshold (>= 2).\n * @param n - Total shares (k <= n <= 255).\n * @param label - Optional caller label.\n */\nexport async function mintShamirRecoveryEntry(\n provider: ShamirRecoveryProvider,\n deks: Map<string, CryptoKey>,\n entryId: string,\n k: number,\n n: number,\n label?: string,\n): Promise<{ entry: ShamirRecoveryEntry; shareStrings: string[] }> {\n const recoverySecret = crypto.getRandomValues(new Uint8Array(32))\n try {\n const credential = bytesToBase64(recoverySecret)\n const blob = await mintWrappedDeksBlob(deks, credential)\n const shareStrings = provider.splitToShares(recoverySecret, k, n)\n const entry: ShamirRecoveryEntry = {\n ...blob, entryId, k, n,\n enrolledAt: new Date().toISOString(),\n ...(label !== undefined && { label }),\n }\n return { entry, shareStrings }\n } finally {\n recoverySecret.fill(0)\n }\n}\n\n/**\n * Decrypt a Shamir recovery entry to recover the raw DEK set.\n *\n * Combines K or more `shares`, reconstructs the recovery secret,\n * unwraps the DEKs via {@link unwrapDeksFromBlob}.\n *\n * Throws (AES-GCM auth-tag mismatch) when the shares don't combine\n * to the secret originally used to mint the entry — typically\n * because they came from a different enrollment or were tampered\n * with. Callers iterating multiple entries should catch.\n */\nexport async function unwrapDeksFromShamirEntry(\n provider: ShamirRecoveryProvider,\n entry: ShamirRecoveryEntry,\n shareStrings: readonly string[],\n): Promise<Map<string, CryptoKey>> {\n if (shareStrings.length < entry.k) {\n throw new Error(\n `Insufficient shares: this Shamir entry needs ${entry.k} of ${entry.n}, `\n + `but ${shareStrings.length} were provided.`,\n )\n }\n const secret = provider.combineShares(shareStrings)\n try {\n return await unwrapDeksFromBlob(entry, bytesToBase64(secret))\n } finally {\n secret.fill(0)\n }\n}\n\nfunction bytesToBase64(b: Uint8Array): string {\n let s = ''\n for (const x of b) s += String.fromCharCode(x)\n return btoa(s)\n}\n\n/**\n * Generate one paper-recovery entry from an unlocked DEK set.\n *\n * Returns the serializable entry (persisted via\n * {@link savePaperRecoveryEntries}). The recovery flow unwraps the\n * DEK set, then mints a fresh KEK from the user's new passphrase.\n *\n * Thin wrapper over {@link mintWrappedDeksBlob} — the crypto\n * lives in the shared primitive; this function just adds paper-\n * recovery's own metadata (`codeId`, `enrolledAt`).\n *\n * @param deks Map of collection-name → DEK (extractable).\n * @param code The plaintext recovery code (caller-supplied;\n * pair this with `@noy-db/on-recovery`'s code\n * generator/parser if available).\n * @param codeId Stable id used by `burnPaperRecoveryEntry`.\n */\nexport async function mintPaperRecoveryEntry(\n deks: Map<string, CryptoKey>,\n code: string,\n codeId: string,\n): Promise<PaperRecoveryEntry> {\n const blob = await mintWrappedDeksBlob(deks, code)\n return {\n ...blob,\n codeId,\n enrolledAt: new Date().toISOString(),\n }\n}\n\n/**\n * Decrypt a recovery entry to recover the raw DEK set. Used by the\n * `recoverPassphrase` flow after the user's code has been parsed.\n *\n * Thin wrapper over {@link unwrapDeksFromBlob}.\n *\n * @throws when the code does not match the entry (AES-GCM auth tag fail).\n */\nexport async function unwrapDeksFromPaperEntry(\n entry: PaperRecoveryEntry,\n code: string,\n): Promise<Map<string, CryptoKey>> {\n return unwrapDeksFromBlob(entry, code)\n}\n\n// Legacy crypto helpers (deriveRecoveryWrappingKey, bytesToBase64,\n// base64ToBytes) were previously inlined here. They now live in the\n// canonical wrap-DEKs primitive at `./wrapped-deks.ts` and are\n// reached via `mintWrappedDeksBlob` / `unwrapDeksFromBlob`.\n","/**\n * Tier-1 change flows — `rotatePassphrase` (user remembers old) and\n * `recoverPassphrase` (user supplies a recovery proof).\n *\n * The two flows share the post-verification half — fresh salt, fresh\n * KEK, rewrap every DEK — and differ only in how they re-derive the\n * old KEK:\n *\n * - **Rotate**: derive from the supplied `oldPassphrase`.\n * - **Recover (paper)**: unwrap from a `RecoveryCodeEntry` using a\n * user-supplied recovery code. The entry is burned on success.\n *\n * The non-paper recovery profiles (Shamir, multi-channel,\n * admin-mediated) are not yet wired — calling them throws\n * {@link RecoveryProfileNotImplementedError} with a tracking link.\n *\n * @module\n */\nimport type { NoydbStore, KeyringFile } from '../types.js'\nimport { NOYDB_KEYRING_VERSION } from '../types.js'\nimport {\n deriveKey,\n generateSalt,\n wrapKey,\n unwrapKey,\n bufferToBase64,\n base64ToBuffer,\n} from '../crypto.js'\nimport { InvalidKeyError, NoAccessError } from '../errors.js'\nimport {\n RecoveryProfileNotImplementedError,\n} from '../policy/errors.js'\nimport {\n loadPaperRecoveryEntries,\n burnPaperRecoveryEntry,\n unwrapDeksFromPaperEntry,\n loadShamirRecoveryEntries,\n unwrapDeksFromShamirEntry,\n type PaperRecoveryEntry,\n type ShamirRecoveryEntry,\n} from './recovery.js'\nimport type { ShamirRecoveryProvider } from './shamir-recovery-provider.js'\nimport { assertStrongPassphrase, type PassphrasePolicy } from '../validation.js'\nimport type { UnlockedKeyring } from './keyring.js'\nimport { mintKeyringCanary } from './keyring.js'\nimport type { KeyringAuthenticator } from '../types.js'\nimport type { EnrollAuthenticatorOptions } from './authenticators.js'\nimport { ValidationError } from '../errors.js'\n\n/**\n * Context handed to a {@link SlotRewrapCeremony} when `rotatePassphrase`\n * preserves a tier-2 slot. The ceremony's job is to re-derive its\n * method-specific wrapping material (PRF assertion, PBKDF2 of the\n * password, etc.) and wrap the freshly rewrapped DEK set under\n * the new wrapping key.\n *\n * Two surfaces are exposed:\n *\n * - `newDeks` — the rewrapped (extractable) DEK set the slot will\n * wrap. This is what `mintPaperRecoveryEntry` / `enrollPassword-\n * Authenticator` / `wrapKeyringSummary` (in `@noy-db/on-webauthn`)\n * all consume; effectively the canonical input for every\n * post-Path C tier-2 ceremony.\n *\n * - `newKek` — the freshly-derived KEK (extractable for the\n * ceremony scope only). Only relevant for forward-compatibility\n * with a hypothetical future on-* package that wants to wrap the\n * KEK itself under a method-derived key. None of the shipped\n * on-* packages need this; they all operate on `newDeks`.\n *\n * The ceremony MUST preserve `oldSlot.id` and `oldSlot.method` in the\n * returned `EnrollAuthenticatorOptions`. Hub validates these — a\n * mismatch throws `ValidationError` (prevents slot-type swap mid-\n * rotation, e.g. converting a webauthn slot to a password slot under\n * cover of preservation).\n */\nexport interface SlotRewrapContext {\n readonly newKek: CryptoKey\n readonly newDeks: Map<string, CryptoKey>\n readonly oldSlot: KeyringAuthenticator\n}\n\n/**\n * Callback that re-enrolls one tier-2 slot during `rotatePassphrase`.\n * Returns the new slot's `EnrollAuthenticatorOptions` — same shape\n * the consumer would pass to `db.enrollAuthenticator` for a fresh\n * enrollment. Hub persists the result atomically with the rotation.\n */\nexport type SlotRewrapCeremony = (\n ctx: SlotRewrapContext,\n) => Promise<EnrollAuthenticatorOptions>\n\n/** Caller payload for {@link rotatePassphrase}. */\nexport interface RotatePassphraseInput {\n readonly oldPassphrase: string\n readonly newPassphrase: string\n readonly passphrasePolicy?: PassphrasePolicy\n readonly allowWeakPassphrase?: boolean\n /**\n * Map of slot id → re-enrolment ceremony. Slots whose id appears\n * here are PRESERVED across rotation (the ceremony re-derives the\n * method-specific wrapping under the new keyring); slots whose id\n * is absent are DROPPED (the pre-slot-ceremony behavior).\n *\n * Without this map, `rotatePassphrase` wipes every tier-2 slot. Consumers building a\n * \"rotate without losing my biometric\" flow supply ceremonies for\n * each slot they want to keep.\n *\n * If a ceremony throws, the entire rotation throws — no partial\n * state. Callers wrap individual ceremonies in try/catch + return\n * a sentinel if they want graceful degradation per slot.\n *\n * Added when slot-ceremony rewrapping landed.\n */\n readonly slotCeremonies?: { readonly [slotId: string]: SlotRewrapCeremony }\n}\n\n/**\n * Re-derive the user's KEK from `oldPassphrase`, rewrap every DEK\n * under a freshly-derived KEK from `newPassphrase`, and persist.\n *\n * Tier-2 authenticator slots are dropped UNLESS the caller supplies\n * a `slotCeremonies` map — each ceremony re-derives its\n * method-specific wrapping under the new keyring, and hub persists\n * the rewrapped slots atomically with the rotation. Slots whose id\n * isn't in the map are still dropped.\n *\n * @throws `InvalidKeyError` if `oldPassphrase` does not unwrap the keyring.\n * @throws `WeakPassphraseError` if `newPassphrase` fails the strength rule.\n * @throws `ValidationError` if a ceremony's result mismatches the\n * slot's id or method (anti-slot-swap guard).\n */\nexport async function rotatePassphrase(\n store: NoydbStore,\n vault: string,\n userId: string,\n input: RotatePassphraseInput,\n): Promise<UnlockedKeyring> {\n if (!input.allowWeakPassphrase) {\n assertStrongPassphrase(input.newPassphrase, input.passphrasePolicy)\n }\n\n const env = await store.get(vault, '_keyring', userId)\n if (!env) {\n throw new NoAccessError(`No keyring found for user \"${userId}\" in vault \"${vault}\".`)\n }\n const file = JSON.parse(env._data) as KeyringFile\n const oldSalt = base64ToBuffer(file.salt)\n const oldKek = await deriveKey(input.oldPassphrase, oldSalt)\n\n // Unwrap every DEK with the OLD KEK first — this also validates the\n // passphrase (a bad KEK throws InvalidKeyError on the first unwrap).\n const deks = new Map<string, CryptoKey>()\n for (const [coll, wrapped] of Object.entries(file.deks)) {\n deks.set(coll, await unwrapKey(wrapped, oldKek))\n }\n\n const newSalt = generateSalt()\n const newKek = await deriveKey(input.newPassphrase, newSalt)\n\n // Rewrap with the new KEK.\n const wrappedDeks: Record<string, string> = {}\n for (const [coll, dek] of deks) {\n wrappedDeks[coll] = await wrapKey(dek, newKek)\n }\n\n // Slot rewrap. Without slotCeremonies, we drop every existing\n // slot. With a ceremony map, slots whose id appears in the map\n // are preserved; the rest are dropped.\n const oldSlots = file.authenticators ?? []\n const newSlots: KeyringAuthenticator[] = []\n if (input.slotCeremonies && oldSlots.length > 0) {\n for (const oldSlot of oldSlots) {\n const ceremony = input.slotCeremonies[oldSlot.id]\n if (!ceremony) continue // drop — not in slotCeremonies map\n\n const result = await ceremony({ newKek, newDeks: deks, oldSlot })\n\n // Anti-slot-swap guard. The ceremony MUST preserve identity —\n // a mismatch would let the consumer convert a webauthn slot to\n // a password slot mid-rotation, which would silently change\n // the security profile of the slot under cover of \"rotation.\"\n if (result.id !== oldSlot.id) {\n throw new ValidationError(\n `slotCeremonies['${oldSlot.id}'] returned id=\"${result.id}\". ` +\n 'The id must match the rotated slot — a ceremony cannot ' +\n 'change a slot\\'s identity.',\n )\n }\n if (result.method !== oldSlot.method) {\n throw new ValidationError(\n `slotCeremonies['${oldSlot.id}'] returned method=\"${result.method}\", ` +\n `expected \"${oldSlot.method}\". The method must match the rotated ` +\n 'slot — a ceremony cannot change the auth method (e.g. webauthn ' +\n '→ password) under cover of rotation.',\n )\n }\n // wrapKind absent on legacy slots / wrap-KEK enroll inputs; treat as 'kek'.\n const oldWrapKind = oldSlot.wrapKind ?? 'kek'\n const newWrapKind = result.wrapKind ?? 'kek'\n if (oldWrapKind !== newWrapKind) {\n throw new ValidationError(\n `slotCeremonies['${oldSlot.id}'] returned wrapKind=\"${newWrapKind}\", ` +\n `expected \"${oldWrapKind}\". The wrap format must match the rotated ` +\n 'slot — a ceremony cannot change the wrap shape (e.g. wrap-KEK → ' +\n 'wrap-DEKs) under cover of rotation, since that would silently ' +\n 'change the session tier produced at unlock.',\n )\n }\n\n // Build the persisted slot from the ceremony result. Mirrors\n // the same construction `enrollAuthenticator` does — wrap-DEKs\n // variants carry { wrapped_deks, iv }; wrap-KEK variants\n // carry { wrapped_kek }.\n const baseFields = {\n id: result.id,\n method: result.method,\n // Preserve original enrolled_at — rotation is rewrapping, not\n // re-enrollment. The slot's enrolment timestamp tracks when\n // the user originally added the slot, not when it was last\n // rewrapped. Forensics consumers reading enrolled_at are\n // tracking the slot's ORIGIN, not its CURRENT wrapping.\n enrolled_at: oldSlot.enrolled_at,\n enrolled_via_tier: result.enrolled_via_tier ?? oldSlot.enrolled_via_tier,\n meta: result.meta,\n } as const\n const newSlot: KeyringAuthenticator = result.wrapKind === 'deks'\n ? {\n ...baseFields,\n wrapKind: 'deks',\n wrapped_deks: result.wrapped_deks,\n iv: result.iv,\n }\n : {\n ...baseFields,\n wrapped_kek: result.wrapped_kek,\n }\n newSlots.push(newSlot)\n }\n }\n\n const canary = await mintKeyringCanary(newKek)\n const next: KeyringFile = {\n ...file,\n _noydb_keyring: NOYDB_KEYRING_VERSION,\n deks: wrappedDeks,\n salt: bufferToBase64(newSalt),\n authenticators: newSlots,\n canary,\n }\n\n await writeKeyringFile(store, vault, userId, next)\n\n return {\n userId: file.user_id,\n displayName: file.display_name,\n role: file.role,\n permissions: file.permissions,\n deks,\n kek: newKek,\n salt: newSalt,\n authenticators: newSlots,\n ...(file.export_capability !== undefined && { exportCapability: file.export_capability }),\n ...(file.import_capability !== undefined && { importCapability: file.import_capability }),\n }\n}\n\n/**\n * Caller payload for {@link recoverPassphrase}.\n *\n * `paper` and `shamir` are wired end-to-end.\n * The remaining two profiles (`multi-channel`, `admin-mediated`)\n * stay outside the union and throw\n * {@link RecoveryProfileNotImplementedError} at the runtime guard\n * when bypassed via `as unknown as RecoveryProof`.\n */\nexport type RecoveryProof =\n | { readonly profile: 'paper'; readonly payload: { readonly code: string } }\n | { readonly profile: 'shamir'; readonly payload: {\n /** Optional disambiguator when multiple Shamir entries are enrolled.\n * When omitted, hub tries each entry until one combines. */\n readonly entryId?: string\n /** K or more opaque share strings, as returned by `ShamirRecoveryProvider.splitToShares`. */\n readonly shares: ReadonlyArray<string>\n } }\n\nexport interface RecoverPassphraseInput {\n readonly newPassphrase: string\n readonly recoveryProof: RecoveryProof\n readonly passphrasePolicy?: PassphrasePolicy\n readonly allowWeakPassphrase?: boolean\n /**\n * After a successful paper-recovery, replace ALL remaining recovery\n * entries with freshly-minted ones. Defaults to `true` (defensive).\n *\n * Rationale: the user just demonstrated they had access\n * to AT LEAST one code. The remaining codes from the same printed\n * sheet may also be compromised — photographed, leaked via a\n * screen-share slip, or in the hands of whoever stole the sheet.\n * Auto-rotation closes the window without requiring consumer action.\n *\n * Set to `false` to preserve the original behavior (only the matched\n * code is burned; the rest stay valid).\n *\n * Hub-side orchestration is non-atomic with the recovery itself:\n * if the rotation step fails after a successful burn, the user\n * falls back to the pre-rotation state (remaining codes still\n * valid). Strictly safer than the previous default — a failed\n * rotation degrades gracefully rather than leaving the vault\n * locked or codes dual-existing.\n */\n readonly rotateRemainingCodes?: boolean\n /**\n * Number of fresh codes to mint when `rotateRemainingCodes` is on.\n * Defaults to the count of remaining entries POST-burn (e.g. if\n * the user enrolled 8 originally and just consumed 1, defaults to\n * 7). Pass an explicit number to mint a different count — useful\n * when the consumer wants to refresh to a target N regardless of\n * how many were left.\n */\n readonly newCodeCount?: number\n /**\n * Override the default raw-code generator. The default is hub's\n * {@link generateULID} — uppercase Crockford-Base32, 26 chars,\n * passes through `normalizePaperCode` untouched.\n *\n * Pass `() => generateRawCode()` from `@noy-db/on-recovery` when\n * the consumer prefers the Base32 + checksum format with hyphenated\n * display. The `mintPaperRecoveryEntry` helper accepts any string —\n * the generator just needs to produce a high-entropy unique value.\n */\n readonly codeGenerator?: () => string\n}\n\n/**\n * Return shape of `db.recoverPassphrase`. `newCodes` is populated when\n * `rotateRemainingCodes` was enabled and at least one entry was\n * rotated; an empty array means no rotation happened (rotation\n * disabled, or no remaining codes after burn). Show the codes to the\n * user once — they are the canonical credential for future recovery\n * and CANNOT be retrieved again.\n */\nexport interface RecoverPassphraseResult {\n readonly newCodes: readonly string[]\n}\n\n/**\n * Input for {@link Noydb.rotateRecovery} — deliberate\n * recovery-credential regeneration when the user knows their\n * passphrase but wants a fresh sheet (paper) or fresh shares\n * (shamir). Symmetric to {@link RotatePassphraseInput}.\n */\nexport type RotateRecoveryOptions =\n | {\n readonly profile: 'paper'\n /** How many fresh codes to mint. Default: existing sheet size. */\n readonly count?: number\n /** Optional code generator — see {@link RecoverPassphraseInput.codeGenerator}. */\n readonly codeGenerator?: () => string\n }\n | {\n readonly profile: 'shamir'\n /** New threshold. */\n readonly k: number\n /** New total share count. */\n readonly n: number\n /** Disambiguator when multiple Shamir entries exist; required if there are 2+. */\n readonly entryId?: string\n /** Optional updated label. */\n readonly label?: string\n }\n\n/**\n * Result of {@link Noydb.rotateRecovery}. Shape varies by profile:\n *\n * - `paper` → `{ newCodes: string[] }` (and `entryId === 'paper-batch'`)\n * - `shamir` → `{ newShares: string[], entryId }`\n *\n * `newCodes` is populated for paper rotations; `newShares` for\n * Shamir rotations. Both are show-once — the hub does not\n * retain them.\n */\nexport interface RotateRecoveryResult {\n readonly newCodes?: readonly string[]\n readonly newShares?: readonly string[]\n readonly entryId?: string\n}\n\n/**\n * Result of {@link Noydb.enrollRecovery}. Shape varies by profile:\n *\n * - `paper` → `{ entryId: 'paper-batch' }` (caller minted the\n * entries; this is a sentinel since paper enrollments are batch-shaped).\n * - `shamir` → `{ entryId, shares: string[] }` — shares are\n * show-once; the hub does not retain them.\n */\nexport interface EnrollRecoveryResult {\n readonly entryId: string\n readonly shares?: readonly string[]\n}\n\n/**\n * Input shape for {@link Noydb.enrollRecovery} and\n * {@link Noydb.openVaultAndEnrollRecovery}. Discriminated\n * union over recovery profiles.\n *\n * - `paper`: caller pre-mints entries (typically via\n * `mintPaperRecoveryEntry` or `@noy-db/on-recovery`'s\n * `generateRecoveryCodeSet`) and passes them in. The hub stores\n * them and surfaces an opaque batch id.\n * - `shamir`: hub mints the recovery secret + the shares at\n * enrollment time. The shares are returned in\n * {@link EnrollRecoveryResult.shares} (show-once); the hub never\n * retains them.\n *\n * Multi-channel and admin-mediated will be added when the respective\n * dispatch slices ship.\n */\nexport type RecoveryEnrollmentInput =\n | { readonly profile: 'paper'; readonly entries: ReadonlyArray<PaperRecoveryEntry> }\n | {\n readonly profile: 'shamir'\n readonly k: number\n readonly n: number\n readonly label?: string\n readonly entryId?: string\n }\n\n/**\n * Reset the user's passphrase using a recovery proof.\n * Supports `'paper'` and `'shamir'` profiles. The other profiles throw\n * {@link RecoveryProfileNotImplementedError}.\n *\n * On success, the used recovery entry is burned (deleted from the\n * stored set).\n */\nexport async function recoverPassphrase(\n provider: ShamirRecoveryProvider | undefined,\n store: NoydbStore,\n vault: string,\n userId: string,\n input: RecoverPassphraseInput,\n): Promise<UnlockedKeyring> {\n if (!input.allowWeakPassphrase) {\n assertStrongPassphrase(input.newPassphrase, input.passphrasePolicy)\n }\n\n // Runtime defense-in-depth: the type narrows to 'paper' | 'shamir',\n // but a consumer bypassing TS via\n // `as unknown as RecoveryProof` should still hit a clear error\n // rather than silently fall into a handler with a malformed payload.\n const profile = (input.recoveryProof as { profile: string }).profile\n if (profile === 'paper') {\n return recoverViaPaperCode(store, vault, userId, input)\n }\n if (profile === 'shamir') {\n return recoverViaShamir(provider, store, vault, userId, input)\n }\n throw new RecoveryProfileNotImplementedError(\n profile,\n 'https://github.com/vLannaAi/noy-db/issues/196',\n )\n}\n\nasync function recoverViaPaperCode(\n store: NoydbStore,\n vault: string,\n userId: string,\n input: RecoverPassphraseInput,\n): Promise<UnlockedKeyring> {\n if (input.recoveryProof.profile !== 'paper') throw new Error('unreachable')\n const { code } = input.recoveryProof.payload\n\n const env = await store.get(vault, '_keyring', userId)\n if (!env) {\n throw new NoAccessError(`No keyring found for user \"${userId}\" in vault \"${vault}\".`)\n }\n const file = JSON.parse(env._data) as KeyringFile\n\n const entries = await loadPaperRecoveryEntries(store, vault)\n if (entries.length === 0) {\n throw new NoAccessError(\n `No paper-recovery entries enrolled for vault \"${vault}\". ` +\n 'Enroll via `db.enrollRecovery({ profile: \"paper\", entries })` before relying on recovery.',\n )\n }\n\n const normalized = normalizePaperCode(code)\n let recovered: { deks: Map<string, CryptoKey>; entry: PaperRecoveryEntry } | undefined\n for (const entry of entries) {\n try {\n const deks = await unwrapDeksFromPaperEntry(entry, normalized)\n recovered = { deks, entry }\n break\n } catch {\n // wrong code for this entry — try the next one\n }\n }\n if (!recovered) {\n throw new InvalidKeyError(\n 'Recovery code does not match any enrolled paper entry. The code may have been ' +\n 'previously used (single-use) or typed incorrectly.',\n )\n }\n\n const deks = recovered.deks\n\n // Fresh salt + KEK from the new passphrase, rewrap.\n const newSalt = generateSalt()\n const newKek = await deriveKey(input.newPassphrase, newSalt)\n const wrappedDeks: Record<string, string> = {}\n for (const [coll, dek] of deks) {\n wrappedDeks[coll] = await wrapKey(dek, newKek)\n }\n\n const canary = await mintKeyringCanary(newKek)\n const next: KeyringFile = {\n ...file,\n _noydb_keyring: NOYDB_KEYRING_VERSION,\n deks: wrappedDeks,\n salt: bufferToBase64(newSalt),\n authenticators: [], // tier-2 slots wrap old KEK, drop them\n canary,\n }\n\n // Burn first, then rewrite the keyring. The two writes are not\n // atomic — if the second fails, the safer ordering is:\n //\n // 1. Code burned, keyring untouched: user keeps their old passphrase\n // and loses one recovery code (recoverable: contact admin / use\n // another code).\n //\n // 2. Keyring rewritten, code unburned: user has rotated, but the\n // consumed code REMAINS VALID. Anyone with access to the paper\n // sheet can use it again. Security regression.\n //\n // Burning first picks (1) over (2).\n await burnPaperRecoveryEntry(store, vault, recovered.entry.codeId)\n await writeKeyringFile(store, vault, userId, next)\n\n return {\n userId: file.user_id,\n displayName: file.display_name,\n role: file.role,\n permissions: file.permissions,\n deks,\n kek: newKek,\n salt: newSalt,\n authenticators: [],\n ...(file.export_capability !== undefined && { exportCapability: file.export_capability }),\n ...(file.import_capability !== undefined && { importCapability: file.import_capability }),\n }\n}\n\n/**\n * Mirror of `@noy-db/on-recovery/parseRecoveryCode`. Inlined so the\n * hub does not gain a peer dep on on-recovery — both implementations\n * follow the same RFC 4648 Base32 + checksum format and round-trip\n * through the same KDF.\n *\n * Accepts hyphenated, lowercase, or whitespace-padded input.\n */\nfunction normalizePaperCode(input: string): string {\n return input.toUpperCase().replace(/[\\s\\-_]/g, '')\n}\n\n/**\n * Recover the user's keyring via the Shamir profile.\n *\n * 1. Decode each supplied share string into a {@link RawShare}.\n * 2. Load `_meta/recovery-shamir` entries.\n * 3. If `payload.entryId` is supplied, restrict to that entry; else\n * iterate over all entries and try each until one combines.\n * 4. For each candidate: filter shares to those whose `(k, n)`\n * match the entry's parameters, then attempt\n * `unwrapDeksFromShamirEntry`. AES-GCM auth-tag failure means\n * the combined secret doesn't match — try the next entry.\n * 5. With unwrapped DEKs: derive fresh KEK from `newPassphrase` +\n * fresh salt, rewrap, write the keyring.\n * 6. Shamir entries are NOT burned on recovery (shares reusable);\n * explicit {@link Noydb.rotateRecovery} is the refresh ceremony.\n */\nasync function recoverViaShamir(\n provider: ShamirRecoveryProvider | undefined,\n store: NoydbStore,\n vault: string,\n userId: string,\n input: RecoverPassphraseInput,\n): Promise<UnlockedKeyring> {\n if (input.recoveryProof.profile !== 'shamir') throw new Error('unreachable')\n const { entryId: requestedEntryId, shares: shareStrings } = input.recoveryProof.payload\n\n if (shareStrings.length === 0) {\n throw new ValidationError(\n 'Shamir recovery requires at least one share; received an empty array.',\n )\n }\n\n const env = await store.get(vault, '_keyring', userId)\n if (!env) {\n throw new NoAccessError(`No keyring found for user \"${userId}\" in vault \"${vault}\".`)\n }\n const file = JSON.parse(env._data) as KeyringFile\n\n const allEntries = await loadShamirRecoveryEntries(store, vault)\n if (allEntries.length === 0) {\n throw new NoAccessError(\n `No Shamir-recovery entries enrolled for vault \"${vault}\". `\n + 'Enroll via `db.enrollRecovery({ profile: \"shamir\", k, n })` before relying on recovery.',\n )\n }\n\n if (!provider) {\n throw new Error(\n \"shamir recovery requires a ShamirRecoveryProvider — pass \"\n + \"shamirRecovery: shamirRecoveryProvider() from '@noy-db/on-shamir' to createNoydb()\",\n )\n }\n\n // Restrict to a specific entry when entryId supplied.\n let candidates: ReadonlyArray<ShamirRecoveryEntry>\n if (requestedEntryId !== undefined) {\n candidates = allEntries.filter(e => e.entryId === requestedEntryId)\n if (candidates.length === 0) {\n throw new NoAccessError(\n `No Shamir-recovery entry with entryId=\"${requestedEntryId}\" found `\n + `in vault \"${vault}\". Available entries: `\n + allEntries.map(e => `\"${e.entryId}\"`).join(', '),\n )\n }\n } else {\n candidates = allEntries\n }\n\n // Try each candidate entry. Pass all share strings to the provider;\n // provider.combineShares validates and throws on mismatch — the\n // AES-GCM auth-tag is an additional guard.\n let recoveredDeks: Map<string, CryptoKey> | undefined\n for (const entry of candidates) {\n if (shareStrings.length < entry.k) {\n // Not enough shares for this entry — could still match another.\n continue\n }\n try {\n const deks = await unwrapDeksFromShamirEntry(provider, entry, shareStrings)\n recoveredDeks = deks\n break\n } catch {\n // provider.combineShares threw (malformed/mismatched shares) or\n // AES-GCM auth-tag failure → try the next entry.\n }\n }\n\n if (!recoveredDeks) {\n // Distinguish \"below-threshold\" from \"no entry matches\" so the\n // error message is actionable.\n const minK = Math.min(...candidates.map(e => e.k))\n if (shareStrings.length < minK) {\n throw new InvalidKeyError(\n `Insufficient Shamir shares to combine: the smallest enrolled threshold is ${minK}, `\n + `but only ${shareStrings.length} share${shareStrings.length === 1 ? ' was' : 's were'} provided.`,\n )\n }\n throw new InvalidKeyError(\n 'Shamir shares do not match any enrolled entry. Possible causes: '\n + 'shares were tampered with, came from a different enrollment, '\n + 'or the entry was rotated after these shares were distributed.',\n )\n }\n\n // Mint fresh KEK from new passphrase, rewrap DEKs (mirrors paper).\n const newSalt = generateSalt()\n const newKek = await deriveKey(input.newPassphrase, newSalt)\n const wrappedDeks: Record<string, string> = {}\n for (const [coll, dek] of recoveredDeks) {\n wrappedDeks[coll] = await wrapKey(dek, newKek)\n }\n\n const canary = await mintKeyringCanary(newKek)\n const next: KeyringFile = {\n ...file,\n _noydb_keyring: NOYDB_KEYRING_VERSION,\n deks: wrappedDeks,\n salt: bufferToBase64(newSalt),\n authenticators: [], // tier-2 slots wrap old KEK, drop them on recovery\n canary,\n }\n\n // No burn: Shamir entries persist across recoveries. Explicit\n // rotateRecovery is the refresh ceremony.\n await writeKeyringFile(store, vault, userId, next)\n\n return {\n userId: file.user_id,\n displayName: file.display_name,\n role: file.role,\n permissions: file.permissions,\n deks: recoveredDeks,\n kek: newKek,\n salt: newSalt,\n authenticators: [],\n ...(file.export_capability !== undefined && { exportCapability: file.export_capability }),\n ...(file.import_capability !== undefined && { importCapability: file.import_capability }),\n }\n}\n\nasync function writeKeyringFile(\n store: NoydbStore,\n vault: string,\n userId: string,\n file: KeyringFile,\n): Promise<void> {\n const envelope = {\n _noydb: 1 as const,\n _v: 1,\n _ts: new Date().toISOString(),\n _iv: '',\n _data: JSON.stringify(file),\n }\n await store.put(vault, '_keyring', userId, envelope)\n}\n","/**\n * Atomic peer-recovery primitive.\n *\n * `recoverUser` is a SEPARATE operation from `revoke + grant`. It\n * exists because peer-recovery has different semantics than account\n * removal-then-reissue:\n *\n * 1. **Same identity preserved.** `userId`, `role`, `permissions`,\n * capability bits, user envelope (if any), policy override (if\n * any) all survive. Only the wrapping changes.\n * 2. **No key rotation.** The existing DEKs stay valid — every\n * OTHER principal in the vault keeps their access. Rotating\n * keys would invalidate every co-user's wrapping.\n * 3. **Atomic by construction.** A single `store.put` overwrites\n * `_keyring/<userId>` with the recovered file. No revoke step\n * means no partial-failure window.\n * 4. **Owner→owner natively allowed.** Two co-owners recovering\n * each other is the explicitly-intentional case (a partner\n * forgot the master phrase). The existing `canRevoke` rule that\n * blocks owner→owner is correct for `revoke` (which is account\n * *removal*) and intentionally NOT replicated here. The policy\n * gate `peer-recover-user` carries the freshness requirement.\n * 5. **Tier-2 slots dropped.** The slots wrap the OLD KEK under\n * method-derived keys; after recovery the KEK is re-derived\n * from the new temp passphrase. Match `rotatePassphrase`'s\n * precedent — the recovered user re-enrols slots after picking\n * their own phrase.\n *\n * Caller must be at least as privileged as the target. The hub\n * `db.recoverUser` method gates this with the `peer-recover-user`\n * policy gate (the `peer-recover-user` factor-proof requirement); the function below\n * enforces only the role + anti-privilege-escalation invariants.\n *\n * @module\n */\nimport type { NoydbStore, KeyringFile, Role } from '../types.js'\nimport { NOYDB_KEYRING_VERSION } from '../types.js'\nimport { deriveKey, generateSalt, wrapKey, bufferToBase64 } from '../crypto.js'\nimport { NoAccessError, PermissionDeniedError, PrivilegeEscalationError } from '../errors.js'\nimport { assertStrongPassphrase, type PassphrasePolicy } from '../validation.js'\nimport type { UnlockedKeyring } from './keyring.js'\nimport { mintKeyringCanary } from './keyring.js'\n\nconst ADMIN_RECOVERABLE_TARGETS: readonly Role[] = ['operator', 'viewer', 'client', 'admin']\n\n/**\n * Whether `callerRole` may recover `targetRole`.\n *\n * Differs from `canRevoke` (in `keyring.ts`) in one critical place:\n * **owner→owner IS allowed**. Peer recovery is the explicitly\n * intentional case (a co-owner forgot their phrase); the freshness\n * binding lives in the `peer-recover-user` policy gate, not in the\n * permission predicate.\n *\n * Admins can recover everyone they could grant (operator / viewer /\n * client / admin) but NOT owners — that boundary stays as a hard\n * structural rule even under recovery.\n */\nfunction canRecover(callerRole: Role, targetRole: Role): boolean {\n if (callerRole === 'owner') return true\n if (callerRole === 'admin') return ADMIN_RECOVERABLE_TARGETS.includes(targetRole)\n return false\n}\n\n/** Input shape for {@link recoverUser}. */\nexport interface RecoverUserOptions {\n /** Target user id whose keyring is being recovered. */\n readonly userId: string\n /**\n * Temporary passphrase under which the new keyring is wrapped.\n * The recipient should call `db.rotatePassphrase` immediately on\n * acceptance to choose their own phrase — this temp acts as a\n * single-use bridge in invite / peer-recovery flows.\n */\n readonly passphrase: string\n /** Override the target's role. Defaults to the existing target's role. */\n readonly role?: Role\n /** Override the target's display name. Defaults to existing. */\n readonly displayName?: string\n /** Validate phrase strength against the configured policy. */\n readonly validatePassphrase?: boolean\n /**\n * Skip phrase strength validation even when `validatePassphrase` is\n * set. The escape hatch matches `grant`'s shape — used when the\n * temp phrase is a high-entropy one-shot string that doesn't need\n * to satisfy the human-typeable rules.\n */\n readonly allowWeakPassphrase?: boolean\n /**\n * Optional explicit phrase policy override (passed through to\n * `assertStrongPassphrase`). Mirrors how `grant` accepts a custom\n * `PassphrasePolicy` for app-specific tightening.\n */\n readonly passphrasePolicy?: PassphrasePolicy\n}\n\n/**\n * Atomically rewrap the target user's keyring under a fresh temp\n * passphrase. Single store write; no revoke step; no key rotation.\n *\n * Caller's responsibilities (NOT enforced here):\n * - Run the `peer-recover-user` policy gate first via\n * `Noydb.checkGate` to enforce the freshness factor proof.\n * - Communicate the temp passphrase to the recipient via a secure\n * channel (URL fragment, in-person, etc.) — the hub does not\n * transport secrets.\n */\nexport async function recoverUser(\n store: NoydbStore,\n vault: string,\n callerKeyring: UnlockedKeyring,\n options: RecoverUserOptions,\n): Promise<void> {\n // 1. Load the target's existing keyring file (plaintext header).\n const env = await store.get(vault, '_keyring', options.userId)\n if (!env) {\n throw new NoAccessError(\n `recoverUser: user \"${options.userId}\" has no keyring in vault \"${vault}\".`,\n )\n }\n const target = JSON.parse(env._data) as KeyringFile\n const targetRole = options.role ?? target.role\n\n // 2. Permission check — caller must be allowed to recover this role.\n // Owner→owner natively allowed; admin→admin allowed; admin→owner blocked.\n if (!canRecover(callerKeyring.role, targetRole)) {\n throw new PermissionDeniedError(\n `Role \"${callerKeyring.role}\" cannot recover role \"${targetRole}\"`,\n )\n }\n // Also guard against role-uplift via the override — admin cannot\n // promote a target to owner under cover of recovery.\n if (!canRecover(callerKeyring.role, target.role)) {\n throw new PermissionDeniedError(\n `Role \"${callerKeyring.role}\" cannot recover role \"${target.role}\"`,\n )\n }\n\n // 3. Anti-privilege-escalation. Every collection the target had\n // access to must be in the caller's DEK set — the recoverer\n // cannot give the recovered user access to a collection the\n // recoverer themselves can't read. Mirrors `grant()`'s check.\n for (const coll of Object.keys(target.deks)) {\n if (!callerKeyring.deks.has(coll)) {\n throw new PrivilegeEscalationError(coll)\n }\n }\n\n // 4. Optional phrase strength validation (mirrors `grant` opt-in).\n if (options.validatePassphrase && !options.allowWeakPassphrase) {\n assertStrongPassphrase(options.passphrase, options.passphrasePolicy)\n }\n\n // 5. Mint a fresh salt + KEK from the temp passphrase. The DEKs\n // themselves are unchanged — only the wrapping is replaced.\n const newSalt = generateSalt()\n const newKek = await deriveKey(options.passphrase, newSalt)\n\n const wrappedDeks: Record<string, string> = {}\n for (const coll of Object.keys(target.deks)) {\n const callerDek = callerKeyring.deks.get(coll)\n if (!callerDek) {\n // Already caught by the anti-privilege-escalation loop above.\n // This branch is defensive belt-and-braces; if it ever fires,\n // the target had a collection the caller's deks Map disagrees\n // with — fail loud rather than silently dropping access.\n throw new PrivilegeEscalationError(coll)\n }\n wrappedDeks[coll] = await wrapKey(callerDek, newKek)\n }\n\n // 6. Build the recovered keyring file. Identity preserved; wrapping\n // refreshed; tier-2 slots dropped (they wrap the OLD KEK and\n // can't survive a tier-1 phrase change — same precedent as\n // rotatePassphrase). Mint a fresh canary under newKek; the\n // OLD canary on the spread `...target` would fail to verify against\n // the new KEK and trip KeyringCorruptError on next load.\n const canary = await mintKeyringCanary(newKek)\n const next: KeyringFile = {\n ...target,\n _noydb_keyring: NOYDB_KEYRING_VERSION,\n role: targetRole,\n display_name: options.displayName ?? target.display_name,\n deks: wrappedDeks,\n salt: bufferToBase64(newSalt),\n granted_by: callerKeyring.userId,\n authenticators: [],\n canary,\n }\n\n // 7. Single atomic write — overwrites the existing envelope.\n // Backend `put` is the canonical write primitive across every\n // `to-*` store; no partial-failure window between revoke + grant.\n const envelope = {\n _noydb: 1 as const,\n _v: 1,\n _ts: new Date().toISOString(),\n _iv: '',\n _data: JSON.stringify(next),\n }\n await store.put(vault, '_keyring', options.userId, envelope)\n}\n","/**\n * Magic-link-bound cross-user delegation grants.\n *\n * This module is the **core storage + encryption layer** that lets a\n * grantor issue a tier-DEK to a user whose KEK they do not know. The\n * trust bridge is provided by the `@noy-db/on-magic-link` package:\n *\n * 1. Grantor picks a grantee identity (user id + email handle).\n * 2. Grantor mints a magic-link token (ULID) via `createMagicLinkToken`.\n * 3. Grantor derives a **content key** + a **KEK** from\n * `(serverSecret, token, vault)` using HKDF-SHA256 with separate\n * `info` tags — both callers (grantor and grantee) can derive the\n * same keys given the same inputs.\n * 4. Grantor persists a record in `_magic_link_grants/<token>`:\n * - envelope `_data` is AES-GCM encrypted under the content key\n * - the inner `wrappedDek` is AES-KW wrapped under the KEK\n * 5. Grantee receives the URL, derives the same content key + KEK,\n * loads the grant, decrypts the envelope, unwraps the tier DEK.\n *\n * ## Why a separate collection from `_delegations`\n *\n * `_delegations` envelopes are encrypted under a DEK shared across\n * every vault user (audit-visibility). External auditors / client\n * portal users have NO pre-existing keyring, so they cannot read that\n * DEK. Magic-link grants live in their own collection whose envelope\n * encryption is derived purely from the magic-link URL + server secret\n * — nothing else is required to decrypt.\n *\n * ## Batch grants\n *\n * One magic-link token may point to MULTIPLE grants (e.g. the client\n * portal case: invoices + payments + etax all share one link). Each\n * grant is persisted under a distinct record id:\n *\n * `<token>` for the single-grant / primary entry\n * `<token>:<index>` for subsequent entries\n *\n * `listMagicLinkGrants(store, vault, token)` enumerates every record\n * whose id begins with `<token>` so the claimant can materialize all\n * DEKs in one pass.\n *\n * ## Revocation\n *\n * `store.delete(vault, _magic_link_grants, <token>)` immediately\n * invalidates the link — even if the URL was captured and the server\n * secret leaked, no payload remains to decrypt.\n *\n * @module\n */\n\nimport type { NoydbStore, EncryptedEnvelope } from '../types.js'\nimport type { UnlockedKeyring } from './keyring.js'\nimport { encrypt, decrypt, wrapKey, unwrapKey } from '../crypto.js'\nimport { dekKey } from './tiers.js'\nimport { DelegationTargetMissingError } from '../errors.js'\n\n/** Reserved collection holding magic-link grant envelopes. */\nexport const MAGIC_LINK_GRANTS_COLLECTION = '_magic_link_grants'\n\n/** HKDF `info` for the AES-GCM content key. Version-namespaced. */\nexport const MAGIC_LINK_CONTENT_INFO_PREFIX = 'noydb-magic-link-content-v1:'\n\n/** HKDF `info` for the AES-KW KEK. Matches `@noy-db/on-magic-link`. */\nexport const MAGIC_LINK_KEK_INFO_PREFIX = 'noydb-magic-link-v1:'\n\n// ─── Types ──────────────────────────────────────────────────────────────\n\n/**\n * Decrypted payload of a magic-link grant record. Mirrors\n * `DelegationToken` in `team/delegation.ts` but tracked separately\n * because the two flows persist under different collections + envelope\n * encryption schemes.\n */\nexport interface MagicLinkGrantPayload {\n readonly id: string\n readonly toUser: string\n readonly fromUser: string\n readonly tier: number\n /** Collection name or `null` for the vault-wide tier DEK. */\n readonly collection: string | null\n /** Optional specific record id scope. */\n readonly record?: string\n /** ISO timestamp — grant expires at this instant. */\n readonly until: string\n /** AES-KW-wrapped tier DEK, unwrap with the magic-link KEK. */\n readonly wrappedDek: string\n /** ISO timestamp the grant was issued. */\n readonly createdAt: string\n /** Optional caller-provided label (surfaced in audit UIs). */\n readonly note?: string\n}\n\nexport interface IssueMagicLinkGrantOptions {\n readonly toUser: string\n readonly tier: number\n readonly collection?: string\n readonly record?: string\n readonly until: Date | string\n readonly note?: string\n}\n\nexport interface MagicLinkGrantRecord {\n /** Store record id — `<token>` or `<token>:<index>` for batch entries. */\n readonly recordId: string\n readonly payload: MagicLinkGrantPayload\n}\n\n// ─── Key derivation ─────────────────────────────────────────────────────\n\n/**\n * Derive the AES-GCM content key from the same HKDF inputs used for\n * the magic-link KEK. Different `info` suffix → domain-separated key.\n *\n * Exported so the `@noy-db/on-magic-link` package can share the exact\n * derivation path without cross-dependency between the two modules.\n */\nexport async function deriveMagicLinkContentKey(\n serverSecret: string | Uint8Array<ArrayBuffer>,\n token: string,\n vault: string,\n): Promise<CryptoKey> {\n const subtle = globalThis.crypto.subtle\n const ikmBytes =\n serverSecret instanceof Uint8Array\n ? serverSecret\n : new TextEncoder().encode(serverSecret)\n const tokenBytes = new TextEncoder().encode(token)\n const saltBuffer = await subtle.digest('SHA-256', tokenBytes)\n const info = new TextEncoder().encode(MAGIC_LINK_CONTENT_INFO_PREFIX + vault)\n const ikm = await subtle.importKey('raw', ikmBytes, 'HKDF', false, ['deriveKey'])\n return subtle.deriveKey(\n { name: 'HKDF', hash: 'SHA-256', salt: saltBuffer, info },\n ikm,\n { name: 'AES-GCM', length: 256 },\n false,\n ['encrypt', 'decrypt'],\n )\n}\n\n// ─── Issue ──────────────────────────────────────────────────────────────\n\n/**\n * Persist a magic-link grant record. Caller derives + provides both\n * the content key and the KEK; this function performs the wrap/encrypt\n * and writes the envelope.\n *\n * `recordId` lets the caller use either the bare token (primary grant)\n * or a suffixed id (batch entry). The writer is responsible for\n * collision-avoidance across batch entries.\n */\nexport async function writeMagicLinkGrant(\n store: NoydbStore,\n vault: string,\n grantor: UnlockedKeyring,\n contentKey: CryptoKey,\n grantKek: CryptoKey,\n recordId: string,\n opts: IssueMagicLinkGrantOptions,\n): Promise<MagicLinkGrantRecord> {\n const collectionName = opts.collection ?? null\n const sourceKey = collectionName\n ? dekKey(collectionName, opts.tier)\n : `__any#${opts.tier}`\n const sourceDek = grantor.deks.get(sourceKey)\n if (!sourceDek) {\n throw new DelegationTargetMissingError(\n `grantor cannot find tier ${opts.tier} DEK for ${collectionName ?? '(any)'}`,\n )\n }\n const wrappedDek = await wrapKey(sourceDek, grantKek)\n\n const until = typeof opts.until === 'string' ? opts.until : opts.until.toISOString()\n const createdAt = new Date().toISOString()\n const payload: MagicLinkGrantPayload = {\n id: recordId,\n toUser: opts.toUser,\n fromUser: grantor.userId,\n tier: opts.tier,\n collection: collectionName,\n ...(opts.record && { record: opts.record }),\n until,\n wrappedDek,\n createdAt,\n ...(opts.note && { note: opts.note }),\n }\n\n const { iv, data } = await encrypt(JSON.stringify(payload), contentKey)\n const envelope: EncryptedEnvelope = {\n _noydb: 1,\n _v: 1,\n _ts: createdAt,\n _iv: iv,\n _data: data,\n _by: grantor.userId,\n }\n await store.put(vault, MAGIC_LINK_GRANTS_COLLECTION, recordId, envelope)\n return { recordId, payload }\n}\n\n// ─── Claim ──────────────────────────────────────────────────────────────\n\n/**\n * Fetch + decrypt a single magic-link grant record by id. Returns null\n * when the record is absent OR when decryption fails (wrong server\n * secret, wrong vault, tampered envelope) — callers treat a null as\n * \"this URL is not valid for this server\".\n *\n * The returned payload's `wrappedDek` is still AES-KW-wrapped; the\n * caller unwraps it with the magic-link KEK to obtain the tier DEK.\n */\nexport async function readMagicLinkGrantRecord(\n store: NoydbStore,\n vault: string,\n contentKey: CryptoKey,\n recordId: string,\n): Promise<MagicLinkGrantPayload | null> {\n const env = await store.get(vault, MAGIC_LINK_GRANTS_COLLECTION, recordId)\n if (!env) return null\n try {\n const json = await decrypt(env._iv, env._data, contentKey)\n return JSON.parse(json) as MagicLinkGrantPayload\n } catch {\n return null\n }\n}\n\n/**\n * Enumerate every grant record sharing the magic-link `token` prefix\n * (i.e. the primary `<token>` entry plus any `<token>:*` batch entries).\n * Expired grants are still returned — the caller filters on `until`.\n */\nexport async function listMagicLinkGrants(\n store: NoydbStore,\n vault: string,\n contentKey: CryptoKey,\n token: string,\n): Promise<MagicLinkGrantPayload[]> {\n const ids = await store.list(vault, MAGIC_LINK_GRANTS_COLLECTION)\n const matching = ids.filter(id => id === token || id.startsWith(`${token}:`))\n const out: MagicLinkGrantPayload[] = []\n for (const id of matching) {\n const payload = await readMagicLinkGrantRecord(store, vault, contentKey, id)\n if (payload) out.push(payload)\n }\n return out\n}\n\n/**\n * Unwrap the tier DEK from a grant payload using the magic-link KEK.\n * Thin wrapper around `unwrapKey` — provided so the claimant can avoid\n * importing `crypto.js` directly.\n */\nexport async function unwrapMagicLinkGrant(\n payload: MagicLinkGrantPayload,\n grantKek: CryptoKey,\n): Promise<CryptoKey> {\n return unwrapKey(payload.wrappedDek, grantKek)\n}\n\n/**\n * Delete a magic-link grant (primary + every batch entry sharing the\n * token). Safe to call when nothing exists.\n */\nexport async function revokeMagicLinkGrant(\n store: NoydbStore,\n vault: string,\n token: string,\n): Promise<number> {\n const ids = await store.list(vault, MAGIC_LINK_GRANTS_COLLECTION)\n const matching = ids.filter(id => id === token || id.startsWith(`${token}:`))\n for (const id of matching) {\n await store.delete(vault, MAGIC_LINK_GRANTS_COLLECTION, id)\n }\n return matching.length\n}\n\n// ─── Helpers ────────────────────────────────────────────────────────────\n\n/**\n * Compose the batch-entry record id. `index === 0` → bare token.\n * Subsequent entries use `<token>:<index>` so `store.list()` can\n * enumerate them all by common prefix.\n */\nexport function magicLinkGrantRecordId(token: string, index: number): string {\n return index === 0 ? token : `${token}:${index}`\n}\n\n/**\n * True when the payload's `until` is in the past relative to `now`.\n * Kept here (rather than inlined) so the semantics stay aligned with\n * the canonical `DelegationToken` expiry check.\n */\nexport function isMagicLinkGrantExpired(\n payload: MagicLinkGrantPayload,\n now: Date = new Date(),\n): boolean {\n return payload.until <= now.toISOString()\n}\n"],"mappings":"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","names":["bytesToBase64","deks","subtle"]}